Files
broker/tests/test_dockerfile_consistency.py
T
coreci da15bda6d2 docs(milestone): complete cli-packaging
---ci---
phase: 3
milestone: v0.2
status: complete
requirements:
  covered: [REQ-21, REQ-22, REQ-23]
  partial: []
---/ci---
2026-10-11 00:32:26 +00:00

264 lines
12 KiB
Python

"""Dockerfile consistency tests (G-016 — equivalence made testable).
The Dockerfile documents the docker-user path; the daemonless client
(``scripts/publish_image.py``) is the pipeline's source of truth for
the published image. Byte-identity is NOT required — these tests pin the
equivalence contract: same staged sources (member-set parity — the
docker-staged layout must equal build_cli's staging EXACTLY, or the
packaged shim materializes an empty UI dir and /ui/* 404s in
docker-built images; P3-03/CWE-636), same zipapp invocation, same
entrypoint/working-dir, same pinned base, and a hygienic build context.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))), "scripts")
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
import build_cli # noqa: E402 (scripts/ dir on sys.path)
import publish_image # noqa: E402 (scripts/ dir on sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
APP_SRC = "/app/src/"
DOCKERFILE_IGNORE_PATTERNS = (
".git", ".env", ".env.*", "data/", "*.db", "*.log", "dist/",
"__pycache__/", "tests/", ".ciagent/",
"**/__pycache__/", "**/*.pyc",
)
def read(name):
with open(os.path.join(REPO, name), "r", encoding="utf-8") as handle:
return handle.read()
def _tree_members(prefix, root):
"""Zipapp member names for the tree at ``root`` staged under
``prefix`` (``""`` for the archive root; the prefix itself is
included — zipapp writes directory entries)."""
members = {prefix} if prefix else set()
for current, dirs, files in os.walk(root):
relative = os.path.relpath(current, root)
base = prefix + ("" if relative == "." else relative + "/")
for name in dirs:
members.add(base + name + "/")
for name in files:
members.add(base + name)
return members
def _build_cli_staged_members():
"""The member set build_cli stages, derived via its constants:
ROOT_SOURCES at the root, UI under the coreci_broker_ui/ prefix,
cli/__main__.py staged as __main__.py, _cli_version.py generated."""
members = set(build_cli.ROOT_SOURCES)
members.add(build_cli.STAGED_SHIM)
members.add(build_cli.VERSION_MODULE)
members |= _tree_members(
build_cli.UI_SOURCE_DIR + "/",
os.path.join(REPO, build_cli.UI_SOURCE_DIR))
return members
def _run_zipapp(staged_dir, artifact):
"""The Dockerfile's packing invocation (zipapp writes dir entries;
the member set is interpreter-stable across 3.x)."""
completed = subprocess.run(
[sys.executable, "-m", "zipapp", staged_dir,
"-o", artifact,
"--python", "/usr/bin/env python3", "--compress"],
capture_output=True, text=True)
if completed.returncode != 0:
raise AssertionError(
"zipapp failed ({}): {}".format(
completed.returncode,
(completed.stderr or completed.stdout).strip()))
class DockerfileConsistencyTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.dockerfile = read("Dockerfile")
cls.dockerignore = read(".dockerignore")
# Join backslash continuations into logical stanzas; skip comments.
stanzas = []
for line in cls.dockerfile.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stanzas and stanzas[-1].endswith("\\"):
stanzas[-1] = stanzas[-1].rstrip("\\").rstrip() + " " + stripped
continue
stanzas.append(stripped)
cls.stanzas = stanzas
cls.copy_lines = [s for s in stanzas if s.startswith("COPY ")]
cls.zipapp_lines = [s for s in stanzas
if "zipapp" in s and s.startswith("RUN ")]
cls.version_lines = [s for s in stanzas
if "_cli_version.py" in s
and s.startswith("RUN ")]
def _copy_specs(self):
"""[(sources, dest)] per COPY stanza, dest verified to stage
under /app/src/."""
specs = []
for line in self.copy_lines:
tokens = line.split()[1:]
sources, dest = tokens[:-1], tokens[-1]
self.assertTrue(dest.startswith(APP_SRC),
"COPY destination {} must stage under /app/src/"
.format(dest))
specs.append((sources, dest))
return specs
def _docker_staged_members(self):
"""The member set the COPY stanzas stage under /app/src/ (Docker
semantics: directory CONTENTS land flattened INSIDE the
destination dir; a dest without trailing slash is a file rename),
plus the RUN-generated _cli_version.py."""
members = set()
for sources, dest in self._copy_specs():
if dest.endswith("/"):
prefix = dest[len(APP_SRC):]
for source in sources:
path = os.path.join(REPO, source.rstrip("/"))
self.assertTrue(os.path.exists(path),
"COPY source missing in the repo: {}"
.format(source))
if os.path.isdir(path):
members |= _tree_members(prefix, path)
else:
members.add(prefix + os.path.basename(source))
else:
# file-to-file rename (mirrors build_cli's STAGED_SHIM
# staging — zipapp requires __main__.py at the root)
self.assertEqual(len(sources), 1,
"file-rename COPY stages exactly one "
"source: {}".format(dest))
members.add(dest[len(APP_SRC):])
members.add(build_cli.VERSION_MODULE) # the RUN printf stanza
return members
def test_from_pins_base(self):
from_lines = [line for line in self.dockerfile.splitlines()
if line.startswith("FROM ")]
self.assertEqual(len(from_lines), 1)
self.assertEqual(from_lines[0].split()[1], "python:3.11-slim")
def test_copy_staged_member_set_matches_build_cli_staging(self):
"""The COPY stanzas stage EXACTLY build_cli's member set — set
parity, no extras, no missing (a flattened UI copy or a missing
root __main__.py breaks the packaged boot; P3-03/CWE-636)."""
self.assertEqual(self._docker_staged_members(),
_build_cli_staged_members(),
"docker-staged member set == build_cli-staged "
"member set")
for line in self.copy_lines:
self.assertNotIn("dist/", line,
"no pre-built artifact in the build context")
def test_docker_layout_simulation_member_set_parity(self):
"""Docker-layout simulation gate (P3-03/CWE-636): apply the
Dockerfile COPY semantics to the real repo sources, add the
generated _cli_version.py, zipapp-pack, and the archive's member
set must EQUAL the build_cli-built artifact's member set."""
with tempfile.TemporaryDirectory(prefix="docker-layout-") as tmp:
staged = os.path.join(tmp, "src")
os.makedirs(staged)
for sources, dest in self._copy_specs():
if dest.endswith("/"):
target = os.path.join(staged, dest[len(APP_SRC):])
os.makedirs(target, exist_ok=True)
for source in sources:
path = os.path.join(REPO, source.rstrip("/"))
if os.path.isdir(path):
# directory CONTENTS land inside the dest dir
shutil.copytree(path, target,
dirs_exist_ok=True)
else:
shutil.copy2(
path,
os.path.join(target,
os.path.basename(source)))
else:
self.assertEqual(len(sources), 1)
shutil.copy2(os.path.join(REPO, sources[0]),
os.path.join(staged, dest[len(APP_SRC):]))
with open(os.path.join(staged, build_cli.VERSION_MODULE), "w",
encoding="utf-8") as handle:
handle.write('VERSION = "{}"\n'.format("v0.2-layout-sim"))
docker_artifact = os.path.join(tmp, "docker-layout.pyz")
_run_zipapp(staged, docker_artifact)
with zipfile.ZipFile(docker_artifact) as archive:
docker_members = set(archive.namelist())
with tempfile.TemporaryDirectory(prefix="build-cli-ref-") as dist:
reference = build_cli.build("v0.2-layout-sim", repo=REPO,
dist_dir=dist)
with zipfile.ZipFile(reference) as archive:
build_members = set(archive.namelist())
self.assertEqual(docker_members, build_members,
"docker-layout archive members == build_cli "
"artifact members")
def test_zipapp_invocation_flags_match_build_script(self):
"""Same --python interpreter and --compress flag as build_cli."""
self.assertEqual(len(self.zipapp_lines), 1)
stanza = self.zipapp_lines[0]
self.assertIn('--python "/usr/bin/env python3"', stanza)
self.assertIn("--compress", stanza)
self.assertIn("-m zipapp", stanza)
self.assertIn("/app/src", stanza)
self.assertIn("/app/coreci-broker.pyz", stanza)
def test_version_module_format_parity(self):
"""ARG VERSION feeds the same VERSION = "<value>" format."""
self.assertEqual(len(self.version_lines), 1)
self.assertIn('VERSION = "%s"', self.version_lines[0])
self.assertEqual(build_cli.VERSION_MODULE, "_cli_version.py")
self.assertTrue(any(s.startswith("ARG VERSION=") for s in self.stanzas),
"ARG VERSION provides the version")
def test_entrypoint_and_workdir_match_published_config(self):
"""ENTRYPOINT/WORKDIR equal the daemonless client's pushed config."""
base_config = json.loads('{"config": {}, "rootfs": {}}')
blob = json.loads(publish_image.build_config_blob(
base_config, "sha256:" + "0" * 64, "v-parity"))
entrypoint = blob["config"]["Entrypoint"]
working_dir = blob["config"]["WorkingDir"]
docker_entrypoint = [line for line in self.dockerfile.splitlines()
if line.startswith("ENTRYPOINT ")]
docker_workdir = [line for line in self.dockerfile.splitlines()
if line.startswith("WORKDIR ")]
self.assertEqual(len(docker_entrypoint), 1)
self.assertEqual(json.loads(
docker_entrypoint[0].split(" ", 1)[1]), entrypoint)
self.assertEqual(len(docker_workdir), 1)
self.assertEqual(docker_workdir[0].split(" ", 1)[1], working_dir)
self.assertEqual(working_dir, "/state")
def test_dockerignore_hygiene(self):
entries = {line.strip() for line in self.dockerignore.splitlines()
if line.strip() and not line.strip().startswith("#")}
for pattern in DOCKERFILE_IGNORE_PATTERNS:
self.assertIn(pattern, entries,
".dockerignore must exclude {}".format(pattern))
def test_no_absolute_source_copies(self):
for line in self.copy_lines:
for token in line.split()[1:-1]:
self.assertFalse(token.startswith("/"),
"build context sources are repo-relative")
if __name__ == "__main__":
unittest.main()