da15bda6d2
---ci--- phase: 3 milestone: v0.2 status: complete requirements: covered: [REQ-21, REQ-22, REQ-23] partial: [] ---/ci---
264 lines
12 KiB
Python
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() |