The ORCH-058 staging rebuild (check_staging_image_fresh) builds the image with
the task git-worktree as the docker build context. A fresh worktree holds only
tracked files, but the Dockerfile did `COPY data/ ./data/` — and `data/` (the
SQLite dir) is gitignored, so it is absent from that context: `docker build`
failed with exit 1 ("BUILD-STAGING: docker build failed - aborting"), bouncing
the task off deploy-staging back to development in a loop.
The COPY was dead weight regardless: `data/` is always supplied at runtime as a
bind-mount volume (./data:/app/data, see docker-compose.yml) which shadows
anything baked into the image. Replace it with `RUN mkdir -p /app/data` so the
mountpoint exists without depending on the build context.
Regression guard: test_tc08b_dockerfile_does_not_copy_gitignored_data_dir
forbids COPY of any gitignored path (the worktree-context invariant).
Refs: ORCH-021
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
185 lines
9.4 KiB
Python
185 lines
9.4 KiB
Python
"""ORCH-058 TC-07/08: static + caller-contract guarantees of the provenance plumbing.
|
|
|
|
These assert the *shape* of the deploy artefacts that can't be unit-tested by
|
|
running them (they shell out to docker/ssh on the host):
|
|
|
|
* TC-07 — the deploy hook fail-closes BEFORE `docker tag` when the staging
|
|
image's git-revision label != EXPECTED_REVISION (exit 1), and the
|
|
new `--build-staging` rebuild mode (a) stamps GIT_SHA into the image,
|
|
(b) uses $BUILD_CONTEXT as the build context, (c) recreates 8501 +
|
|
health-checks, (d) runs staging_check against the FRESH image
|
|
(Strategy A step 3, AC-4), and (e) never recomputes GIT_SHA from $REPO.
|
|
* TC-08 — the Dockerfile declares `ARG GIT_SHA` and stamps it into the
|
|
`org.opencontainers.image.revision` OCI label (the anchor B reads).
|
|
* TC-09 — the caller↔hook contract: `rebuild_staging_image` invokes the hook
|
|
in `--build-staging` mode with BUILD_CONTEXT=<host-worktree>,
|
|
GIT_SHA=<validated sha>, and an EXPLICIT staging target (never prod).
|
|
"""
|
|
|
|
import pathlib
|
|
|
|
_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
_HOOK = _ROOT / "scripts" / "orchestrator-deploy-hook.sh"
|
|
_DOCKERFILE = _ROOT / "Dockerfile"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-07: hook fail-closed provenance guard + --build-staging rebuild mode
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc07_hook_has_fail_closed_provenance_guard():
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
# The label key the hook inspects must be the OCI revision label.
|
|
assert 'REVISION_LABEL="org.opencontainers.image.revision"' in text
|
|
# EXPECTED_REVISION is read (default unset -> backward compatible).
|
|
assert 'EXPECTED_REVISION="${EXPECTED_REVISION:-}"' in text
|
|
# The guard must inspect the source image's label and normalise <no value>.
|
|
assert "docker image inspect --format" in text
|
|
assert '"<no value>"' in text
|
|
# Fail-closed: empty OR mismatch -> abort with exit 1.
|
|
assert '-z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION"' in text
|
|
|
|
|
|
def test_tc07_provenance_guard_precedes_docker_tag():
|
|
"""The fail-closed `exit 1` must sit BEFORE the `docker tag` retag line."""
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
guard = text.index("$EXPECTED_REVISION")
|
|
retag = text.index('docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"')
|
|
assert guard < retag, "provenance guard must run before the prod retag"
|
|
|
|
|
|
def test_tc07_build_staging_mode_stamps_git_sha():
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
# The new Strategy-A rebuild mode exists and is keyed on --build-staging.
|
|
assert '"${1:-}" == "--build-staging"' in text
|
|
# It rebuilds the staging image stamping the validated commit as a build-arg.
|
|
assert 'docker build --build-arg GIT_SHA="$GIT_SHA"' in text
|
|
|
|
|
|
def test_tc07_build_staging_uses_build_context_and_recreates_8501():
|
|
"""The rebuild must use $BUILD_CONTEXT as the docker build context and recreate
|
|
the staging service with a health-check (not a bare build)."""
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
# $BUILD_CONTEXT is the build context of the rebuild (validated worktree).
|
|
assert 'docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT"' in text
|
|
# Recreate the staging service on the fresh image (no-build) + health-check.
|
|
assert 'up -d --no-build "$TARGET_SERVICE"' in text
|
|
assert 'health_check 10 6 "build-staging-health"' in text
|
|
|
|
|
|
def test_tc07_build_staging_does_not_recompute_git_sha_from_repo():
|
|
"""Regression guard (root cause of the silent-stale-promote class): the
|
|
--build-staging mode must NOT derive GIT_SHA itself from the prod $REPO clone —
|
|
it must consume the GIT_SHA passed in by the caller (the validated commit)."""
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
# Anchor on the actual block guard (not the header comment mentions).
|
|
after = text[text.index('"${1:-}" == "--build-staging"'):]
|
|
assert 'GIT_SHA="${GIT_SHA:-}"' in after
|
|
assert "git rev-parse" not in after, "GIT_SHA must come from the caller, not the prod clone"
|
|
|
|
|
|
def test_tc07_build_staging_runs_staging_check_against_fresh_image():
|
|
"""Strategy A step 3 (ADR-001, AC-4): after recreate+health, the FRESH image is
|
|
validated by staging_check.py (not health-only). This is the P1 the reviewer
|
|
flagged: validate exactly the artefact later retagged to prod."""
|
|
text = _HOOK.read_text(encoding="utf-8")
|
|
# Anchor on the actual block guard (not the header comment mentions).
|
|
after = text[text.index('"${1:-}" == "--build-staging"'):]
|
|
# staging_check is invoked, inside the staging container, --mode stub by default.
|
|
assert "staging_check.py" in after
|
|
assert 'docker exec "$STAGING_CONTAINER"' in after
|
|
assert '--mode "$STAGING_CHECK_MODE"' in after
|
|
assert 'STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"' in after
|
|
# The staging_check run must come AFTER the health-check (health gates readiness).
|
|
assert after.index('health_check 10 6 "build-staging-health"') < after.index("staging_check.py")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-08: Dockerfile stamps the OCI revision label from a build-arg
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc08_dockerfile_stamps_revision_label():
|
|
text = _DOCKERFILE.read_text(encoding="utf-8")
|
|
assert "ARG GIT_SHA" in text
|
|
assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-08b (ORCH-021 regression): the Dockerfile must not COPY a gitignored path.
|
|
# The ORCH-058 staging rebuild builds with the task *worktree* as the docker build
|
|
# context. A fresh worktree contains only tracked files, so any `COPY <gitignored>`
|
|
# (notably `data/`, the SQLite dir) makes `docker build` fail with exit 1 and bounces
|
|
# the task off `deploy-staging`. `data/` is a runtime bind-mount volume anyway, so it
|
|
# must never be a COPY source.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc08b_dockerfile_does_not_copy_gitignored_data_dir():
|
|
text = _DOCKERFILE.read_text(encoding="utf-8")
|
|
gitignore = (_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
|
|
# Precondition: `data/` really is gitignored (the build context will not have it).
|
|
assert "data/" in [ln.strip() for ln in gitignore]
|
|
# The Dockerfile must not COPY it (would break the worktree-context staging build).
|
|
copy_sources = [
|
|
line.split()[1]
|
|
for line in text.splitlines()
|
|
if line.strip().upper().startswith("COPY") and len(line.split()) >= 3
|
|
]
|
|
assert "data/" not in copy_sources, (
|
|
"Dockerfile must not `COPY data/` — it's gitignored and absent from the "
|
|
"worktree build context used by the ORCH-058 staging rebuild (exit 1)."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-09: caller↔hook contract — rebuild_staging_image builds the right command
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc09_rebuild_staging_image_passes_validated_context_and_staging_target(monkeypatch):
|
|
"""`rebuild_staging_image` must invoke the hook `--build-staging` over ssh with
|
|
BUILD_CONTEXT=<host-worktree>, GIT_SHA=<validated sha>, and an EXPLICIT staging
|
|
target (service/port/profile/container) — never the prod 8500 target. The absence
|
|
of this contract test is what hid the earlier P0s (review P2)."""
|
|
import src.image_freshness as imgf
|
|
|
|
captured = {}
|
|
|
|
class _FakeCompleted:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def _fake_run(cmd, *a, **kw):
|
|
captured["cmd"] = cmd
|
|
return _FakeCompleted()
|
|
|
|
monkeypatch.setattr(imgf, "_ssh_target", lambda: "slin@host")
|
|
monkeypatch.setattr(imgf, "_host_worktree_path",
|
|
lambda repo, branch: "/home/slin/repos/_wt/orchestrator/feature_X")
|
|
monkeypatch.setattr(imgf.subprocess, "run", _fake_run)
|
|
|
|
ok, msg = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123def456")
|
|
assert ok, msg
|
|
|
|
cmd = captured["cmd"]
|
|
assert cmd[0] == "ssh"
|
|
inner = cmd[-1] # the remote shell command string
|
|
# Validated commit + validated worktree as build context.
|
|
assert "GIT_SHA=abc123def456" in inner
|
|
assert "BUILD_CONTEXT=/home/slin/repos/_wt/orchestrator/feature_X" in inner
|
|
# Explicit STAGING target — never the prod 8500 service/port.
|
|
assert "TARGET_SERVICE=orchestrator-staging" in inner
|
|
assert "TARGET_PORT=8501" in inner
|
|
assert "COMPOSE_PROFILE=staging" in inner
|
|
assert "STAGING_CONTAINER=orchestrator-staging" in inner
|
|
assert "orchestrator-orchestrator-staging" in inner # staging TARGET_IMAGE
|
|
assert "--build-staging" in inner
|
|
# Hard safety: the prod service/port must NOT leak into the staging rebuild.
|
|
assert "TARGET_PORT=8500" not in inner
|
|
assert "TARGET_SERVICE=orchestrator " not in inner
|
|
|
|
|
|
def test_tc09_rebuild_staging_image_no_ssh_host_fails_closed(monkeypatch):
|
|
"""No ssh host configured -> never-raise, fail-closed (False), no command run."""
|
|
import src.image_freshness as imgf
|
|
|
|
monkeypatch.setattr(imgf, "_ssh_target", lambda: None)
|
|
ok, reason = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123")
|
|
assert ok is False
|
|
assert "ssh host" in reason
|