molecule-ci/scripts/test_validate_workspace_template.py
Hongming Wang 73102cdaa9 feat(validate-workspace-template): strict drift gate + canonical-fetch workflow
P6 Phase 1: enforce the workspace-template contract via CI on every
template-repo push, eliminating the slow drift that produced 8
copies of a 28-line Dockerfile in different states of decay.

The previous validator (50 lines, soft warnings only) couldn't
catch the cache-trap pattern (Dockerfile missing ARG RUNTIME_VERSION)
that silently shipped the previous runtime wheel during cascade
publishes — observed five times in a row on 2026-04-27. Hardened
into structural checks that fail CI, not just warn:

  - Dockerfile must base on python:3.11-slim
  - Dockerfile must declare ARG RUNTIME_VERSION AND reference
    ${RUNTIME_VERSION} in a RUN block (the arg has to be in the
    layer's command line for docker to hash it into the cache key)
  - Dockerfile must create the agent uid-1000 user (Claude Code
    refuses --dangerously-skip-permissions as root for safety)
  - Dockerfile must end at molecule-runtime — directly via
    ENTRYPOINT or via a wrapper script that exec's it (claude-code
    has entrypoint.sh for gosu drop-priv; hermes has start.sh to
    boot the hermes-agent daemon first; both are allowed)
  - config.yaml must have name + runtime + integer
    template_schema_version. Quoted "1" fails — observed previously
    in a copy-pasted template that the YAML loader turned into str
  - requirements.txt must declare molecule-ai-workspace-runtime

Also fixed: the original validator's warning telling adapter.py
NOT to import molecule_runtime was backwards — that's the
canonical package name post-#87. Now it warns on the legacy
molecule_ai prefix instead.

Reusable workflow change: instead of running
.molecule-ci/scripts/validate-workspace-template.py (a per-template
vendored copy that drifts as the validator evolves), the workflow
now checks out molecule-ci itself into .molecule-ci-canonical and
runs the canonical script from there. Single source of truth —
every template runs the SAME contract on every CI run. The legacy
.molecule-ci/scripts/ directories in each template repo can be
deleted in a Phase 2 cleanup PR.

14 unit tests pin the contract:
  - canonical template passes
  - claude-code-style custom entrypoint passes when the wrapper
    exec's molecule-runtime
  - 5 Dockerfile drift modes each error individually
  - 3 config.yaml drift modes each error/warn
  - requirements.txt missing-runtime errors
  - legacy molecule_ai import warns
  - regression cover: modern molecule_runtime import does NOT
    trigger the (deleted) backwards warning

All 8 production template repos pass the new contract today —
this PR locks in the current good state, it does not force any
template-repo edits.

Contract documented at docs/template-contract.md so the rules are
discoverable without reading the validator.
2026-04-27 14:50:55 -07:00

276 lines
11 KiB
Python

"""Tests for validate-workspace-template.py — pin the drift contract.
Each test materialises a tiny template directory in a tmpdir, runs the
validator's check functions in-process, and asserts on the captured
ERRORS / WARNINGS lists. The 8 template repos in the wild are the
ground-truth integration test (CI runs this validator against each on
push), but those repos can change at any time. These tests pin the
contract itself so a refactor of the validator can't silently weaken
it.
Important: the validator was chosen to be import-safe (no top-level
side effects), so the test patches the cwd via os.chdir into tmpdirs.
The module's ERRORS/WARNINGS lists are reset at the start of each
test via _reset_validator_state().
"""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
import pytest
VALIDATOR_PATH = Path(__file__).resolve().parent / "validate-workspace-template.py"
def _load_validator():
"""Load the validator module by path (its filename has a hyphen so
we can't `import validate-workspace-template` directly)."""
spec = importlib.util.spec_from_file_location("validator", VALIDATOR_PATH)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def validator(monkeypatch):
"""Fresh validator module per test, cwd pinned to tmpdir below."""
mod = _load_validator()
mod.ERRORS.clear()
mod.WARNINGS.clear()
return mod
def _good_dockerfile() -> str:
"""Canonical Dockerfile that should pass every check."""
return (
"FROM python:3.11-slim\n"
"ARG RUNTIME_VERSION=\n"
"RUN useradd -u 1000 -m -s /bin/bash agent\n"
"WORKDIR /app\n"
"COPY requirements.txt .\n"
'RUN pip install -r requirements.txt && \\\n'
' if [ -n "${RUNTIME_VERSION}" ]; then \\\n'
' pip install --upgrade "molecule-ai-workspace-runtime==${RUNTIME_VERSION}"; \\\n'
' fi\n'
'ENTRYPOINT ["molecule-runtime"]\n'
)
def _good_config_yaml() -> str:
return (
"name: test-template\n"
"runtime: claude-code\n"
"template_schema_version: 1\n"
"description: A test template\n"
"tier: 1\n"
)
def _good_requirements_txt() -> str:
return "molecule-ai-workspace-runtime>=0.1.0\n"
def _materialise(tmp_path: Path, dockerfile: str | None = None,
config_yaml: str | None = None,
requirements: str | None = None,
adapter_py: str | None = None) -> None:
if dockerfile is not None:
(tmp_path / "Dockerfile").write_text(dockerfile)
if config_yaml is not None:
(tmp_path / "config.yaml").write_text(config_yaml)
if requirements is not None:
(tmp_path / "requirements.txt").write_text(requirements)
if adapter_py is not None:
(tmp_path / "adapter.py").write_text(adapter_py)
# ───────────────────────────────────────────────────────── happy paths
def test_canonical_template_passes(validator, tmp_path, monkeypatch):
_materialise(
tmp_path,
dockerfile=_good_dockerfile(),
config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt(),
)
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
validator.check_config_yaml()
validator.check_requirements()
validator.check_adapter()
assert validator.ERRORS == [], validator.ERRORS
def test_custom_entrypoint_script_passes_when_it_execs_runtime(validator, tmp_path, monkeypatch):
"""claude-code style: ENTRYPOINT [/entrypoint.sh] + entrypoint.sh
that exec's molecule-runtime at the end. Must pass."""
df = (
"FROM python:3.11-slim\n"
"ARG RUNTIME_VERSION=\n"
"RUN useradd -u 1000 -m -s /bin/bash agent\n"
"COPY requirements.txt .\n"
'RUN pip install -r requirements.txt && \\\n'
' if [ -n "${RUNTIME_VERSION}" ]; then \\\n'
' pip install --upgrade "molecule-ai-workspace-runtime==${RUNTIME_VERSION}"; \\\n'
' fi\n'
"COPY entrypoint.sh /entrypoint.sh\n"
'ENTRYPOINT ["/entrypoint.sh"]\n'
)
ep = (
"#!/bin/sh\n"
"set -e\n"
'# drop privileges then exec the runtime\n'
'exec gosu agent molecule-runtime "$@"\n'
)
_materialise(
tmp_path,
dockerfile=df,
config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt(),
)
(tmp_path / "entrypoint.sh").write_text(ep)
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert validator.ERRORS == [], validator.ERRORS
# ───────────────────────────────────────────────────────── Dockerfile drift
def test_wrong_base_image_errors(validator, tmp_path, monkeypatch):
df = _good_dockerfile().replace("python:3.11-slim", "python:3.10-alpine")
_materialise(tmp_path, dockerfile=df, config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert any("FROM python:3.11-slim" in e for e in validator.ERRORS)
def test_missing_arg_runtime_version_errors(validator, tmp_path, monkeypatch):
"""Without ARG RUNTIME_VERSION, the cascade rebuild silently ships
the previous runtime — the cache trap that bit us 5x on 2026-04-27."""
df = _good_dockerfile().replace("ARG RUNTIME_VERSION=\n", "")
_materialise(tmp_path, dockerfile=df, config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert any("ARG RUNTIME_VERSION" in e for e in validator.ERRORS)
def test_missing_runtime_version_in_run_block_errors(validator, tmp_path, monkeypatch):
"""ARG declared but NEVER referenced in a RUN — same cache-trap,
different shape. Pin both."""
df = (
"FROM python:3.11-slim\n"
"ARG RUNTIME_VERSION=\n"
"RUN useradd -u 1000 -m -s /bin/bash agent\n"
"RUN pip install molecule-ai-workspace-runtime\n"
'ENTRYPOINT ["molecule-runtime"]\n'
)
_materialise(tmp_path, dockerfile=df, config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert any("RUNTIME_VERSION" in e and "RUN block" in e for e in validator.ERRORS)
def test_missing_agent_user_errors(validator, tmp_path, monkeypatch):
df = _good_dockerfile().replace("RUN useradd -u 1000 -m -s /bin/bash agent\n", "")
_materialise(tmp_path, dockerfile=df, config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert any("agent" in e for e in validator.ERRORS)
def test_missing_entrypoint_errors(validator, tmp_path, monkeypatch):
df = _good_dockerfile().replace('ENTRYPOINT ["molecule-runtime"]\n', "")
_materialise(tmp_path, dockerfile=df, config_yaml=_good_config_yaml(),
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_dockerfile()
assert any("molecule-runtime" in e and ("ENTRYPOINT" in e or "entrypoint" in e)
for e in validator.ERRORS)
# ───────────────────────────────────────────────────────── config.yaml drift
def test_missing_required_keys_errors(validator, tmp_path, monkeypatch):
cfg = "description: only description, no name/runtime/version\n"
_materialise(tmp_path, dockerfile=_good_dockerfile(), config_yaml=cfg,
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_config_yaml()
missing_msgs = [e for e in validator.ERRORS if "missing required key" in e]
assert len(missing_msgs) >= 3 # name, runtime, template_schema_version
def test_string_template_schema_version_errors(validator, tmp_path, monkeypatch):
cfg = (
"name: t\n"
"runtime: claude-code\n"
'template_schema_version: "1"\n' # str, not int
)
_materialise(tmp_path, dockerfile=_good_dockerfile(), config_yaml=cfg,
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_config_yaml()
assert any("template_schema_version must be int" in e for e in validator.ERRORS)
def test_unknown_runtime_warns_not_errors(validator, tmp_path, monkeypatch):
cfg = _good_config_yaml().replace("claude-code", "my-experimental-runtime")
_materialise(tmp_path, dockerfile=_good_dockerfile(), config_yaml=cfg,
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_config_yaml()
assert any("not in known set" in w for w in validator.WARNINGS)
assert validator.ERRORS == [] # custom runtimes are allowed
def test_unknown_top_level_keys_warn(validator, tmp_path, monkeypatch):
cfg = _good_config_yaml() + "weird_drift_key: something\n"
_materialise(tmp_path, dockerfile=_good_dockerfile(), config_yaml=cfg,
requirements=_good_requirements_txt())
monkeypatch.chdir(tmp_path)
validator.check_config_yaml()
assert any("unknown top-level keys" in w and "weird_drift_key" in w
for w in validator.WARNINGS)
# ───────────────────────────────────────────────────────── requirements.txt
def test_missing_runtime_in_requirements_errors(validator, tmp_path, monkeypatch):
_materialise(tmp_path, dockerfile=_good_dockerfile(), config_yaml=_good_config_yaml(),
requirements="fastapi\n")
monkeypatch.chdir(tmp_path)
validator.check_requirements()
assert any("molecule-ai-workspace-runtime" in e for e in validator.ERRORS)
# ───────────────────────────────────────────────────────── adapter.py
def test_legacy_molecule_ai_import_warns(validator, tmp_path, monkeypatch):
"""Pre-#87 package was named differently. Catch any laggards."""
adapter = "from molecule_ai.adapter_base import BaseAdapter\n"
_materialise(tmp_path, adapter_py=adapter)
monkeypatch.chdir(tmp_path)
validator.check_adapter()
assert any("molecule_ai" in w for w in validator.WARNINGS)
def test_modern_molecule_runtime_import_does_not_warn(validator, tmp_path, monkeypatch):
"""Regression cover: the original validator's warning ('don't import
molecule_runtime') was BACKWARDS — that's the canonical name now.
Pin that the new validator does NOT emit a false positive."""
adapter = "from molecule_runtime.adapter_base import BaseAdapter\n"
_materialise(tmp_path, adapter_py=adapter)
monkeypatch.chdir(tmp_path)
validator.check_adapter()
legacy_warnings = [w for w in validator.WARNINGS if "molecule_ai" in w]
assert legacy_warnings == [], legacy_warnings