generate_p3_wp14_release_manifest.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Generate the unsigned, dirty-worktree P3-WP14 local release manifest."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import re
  7. import stat
  8. import subprocess
  9. from dataclasses import dataclass
  10. from pathlib import Path
  11. from p3_wp14_secure_io import atomic_write_bytes, exclusive_lock, read_bytes_once, sha256_file_once
  12. ROOT = Path(__file__).resolve().parents[1]
  13. OUTPUT = ROOT / "docs/phase3/P3_WP14_RELEASE_MANIFEST.json"
  14. TREE_ROOTS = ("app", "deployment/app", "migrations", "deployment/migrations")
  15. FILE_PATHS = (
  16. "docs/architecture/OPENAPI.yaml",
  17. "deploy/docker/docker-compose.yml",
  18. "scripts/generate_openapi.py",
  19. "docs/acceptance/P3_WP14_UAT_CASES.json",
  20. "docs/acceptance/P3_WP14_UAT_PLAN_AND_REPORT.md",
  21. "docs/acceptance/P3_WP14_DEFECT_AND_GATE_LEDGER.md",
  22. "docs/acceptance/P3_WP14_FIVE_PARTY_SIGNOFF.md",
  23. "docs/runbooks/P3_WP14_ENTERPRISE_PILOT_HANDOVER.md",
  24. "docs/validation/P3_WP14_ENTERPRISE_PILOT_EVIDENCE.md",
  25. "docs/phase3/P3_WP14_264_MODULE_CLOSURE_AUDIT.json",
  26. "docs/phase3/P3_WP14_ENTERPRISE_PILOT_ACCEPTANCE_IMPLEMENTATION_PLAN.md",
  27. "docs/validation/P3_WP14_LOCAL_CONFIG_A.json",
  28. "docs/validation/P3_WP14_LOCAL_CONFIG_B.json",
  29. "docs/validation/P3_WP14_LOCAL_CONFIG_COMPARISON.json",
  30. "tests/test_phase3_wp14_acceptance_handover_contract.py",
  31. "scripts/validate_p3_wp14_local_configs.py",
  32. "scripts/generate_p3_wp14_release_manifest.py",
  33. "scripts/generate_p3_wp14_verification_ledger.py",
  34. "scripts/verify_p3_wp14_fresh_local_engineering.py",
  35. "scripts/p3_wp14_secure_io.py",
  36. )
  37. COMPOSE = "deploy/docker/docker-compose.yml"
  38. COMMAND_TIMEOUT_SECONDS = 30
  39. COMMAND_HOME = ROOT / "work/p3_wp14_manifest_command_home"
  40. COMPOSE_EXECUTABLE = "/usr/local/bin/docker-compose"
  41. MANIFEST_LOCK = ROOT / "work/p3_wp14_manifest_lock_scope/lock"
  42. MAX_DOCKERIGNORE_BYTES = 64 * 1024
  43. MAX_TREE_FILES = 20_000
  44. MAX_TREE_TOTAL_BYTES = 512 * 1024 * 1024
  45. MAX_TREE_DEPTH = 32
  46. MAX_TREE_PATH_BYTES = 1024
  47. COMPOSE_INPUTS = (
  48. ("build_context", "."),
  49. ("dockerfile", "deploy/docker/backend.Dockerfile"),
  50. ("dockerfile", "deploy/docker/mcp.Dockerfile"),
  51. ("dockerfile", "deploy/docker/runner.Dockerfile"),
  52. ("dockerfile", "deploy/docker/frontend.Dockerfile"),
  53. ("dockerfile_copy_input", "deploy/docker/nginx.conf"),
  54. ("requirements", "requirements.txt"),
  55. ("requirements", "deployment/requirements.txt"),
  56. ("mount_input", "deploy/docker/postgres/init"),
  57. ("mount_input", "database"),
  58. ("mount_input", "deploy/docker/datasources/postgres/init.sql"),
  59. ("mount_input", "deploy/docker/datasources/mysql/init.sql"),
  60. ("mount_input", "deploy/docker/kestra/application.yml"),
  61. ("mount_input", "deploy/docker/edge-mtls-nginx.conf"),
  62. )
  63. DOCKERFILES = tuple(path for kind, path in COMPOSE_INPUTS if kind == "dockerfile")
  64. def _minimal_command_env() -> dict[str, str]:
  65. """Do not inherit GIT_DIR, GIT_WORK_TREE, compose, or credential overrides."""
  66. return {
  67. "PATH": os.environ.get("PATH", ""),
  68. "HOME": str(COMMAND_HOME),
  69. "DOCKER_CONFIG": str(COMMAND_HOME / "docker"),
  70. "GIT_CONFIG_NOSYSTEM": "1",
  71. "GIT_CONFIG_GLOBAL": "/dev/null",
  72. }
  73. def _run_command(command: list[str], command_env: dict[str, str], *, text: bool) -> subprocess.CompletedProcess:
  74. """Run only from the repository root with an intentionally minimal env."""
  75. return subprocess.run(
  76. command,
  77. cwd=ROOT,
  78. env=command_env,
  79. text=text,
  80. capture_output=True,
  81. check=True,
  82. timeout=COMMAND_TIMEOUT_SECONDS,
  83. )
  84. def _git_top_level(command_env: dict[str, str]) -> str:
  85. top_level = _run_command(["git", "rev-parse", "--show-toplevel"], command_env, text=True).stdout.strip()
  86. if Path(top_level) != ROOT:
  87. raise RuntimeError("unexpected git top-level")
  88. return top_level
  89. def _collect_git_status(command_env: dict[str, str], top_level: str) -> dict[str, object]:
  90. """Return independently recomputable porcelain counts and its exact digest."""
  91. porcelain = _run_command(["git", "status", "--porcelain=v1", "-z"], command_env, text=False).stdout
  92. if not isinstance(porcelain, bytes):
  93. raise RuntimeError("git porcelain was not bytes")
  94. records = porcelain.split(b"\0")
  95. entries: list[bytes] = []
  96. index = 0
  97. while index < len(records):
  98. record = records[index]
  99. if not record:
  100. index += 1
  101. continue
  102. if len(record) < 4 or record[2:3] != b" ":
  103. raise RuntimeError("invalid git porcelain v1 -z record")
  104. xy = record[:2]
  105. entries.append(xy)
  106. index += 1
  107. # With -z, rename/copy records carry a second NUL-delimited old path.
  108. if b"R" in xy or b"C" in xy:
  109. if index >= len(records) or not records[index]:
  110. raise RuntimeError("truncated git porcelain rename/copy record")
  111. index += 1
  112. return {
  113. "command": ["git", "status", "--porcelain=v1", "-z"],
  114. "top_level": top_level,
  115. "entry_count": len(entries),
  116. "tracked": sum(xy != b"??" for xy in entries),
  117. "untracked": sum(xy == b"??" for xy in entries),
  118. "staged": sum(xy[:1] not in {b" ", b"?"} for xy in entries),
  119. "sha256": hashlib.sha256(porcelain).hexdigest(),
  120. }
  121. def _compose_services(command_env: dict[str, str]) -> list[str]:
  122. rendered = _run_command(
  123. [COMPOSE_EXECUTABLE, "--env-file", "/dev/null", "-f", COMPOSE, "config", "--format", "json"], command_env, text=True
  124. )
  125. return sorted(json.loads(rendered.stdout)["services"])
  126. def _image_record(source: str, reference: str) -> dict[str, str]:
  127. immutable = "@sha256:" in reference
  128. return {
  129. "source": source,
  130. "reference": reference,
  131. "status": "IMMUTABLE_DIGEST" if immutable else "UNRESOLVED_MUTABLE_TAG",
  132. "gate": "PASS_LOCAL" if immutable else "BLOCKED_EXTERNAL",
  133. }
  134. def _image_reference_closure(
  135. compose_content: str | None = None, dockerfiles: dict[str, str] | None = None,
  136. ) -> list[dict[str, str]]:
  137. """Audit every Compose image and Dockerfile FROM without contacting a registry."""
  138. compose = compose_content if compose_content is not None else read_bytes_once(ROOT / COMPOSE).decode("utf-8")
  139. records = [
  140. _image_record(f"compose:{number}", match.group(1).strip())
  141. for number, line in enumerate(compose.splitlines(), 1)
  142. if (match := re.match(r"\s*image:\s*([^#\s]+)", line))
  143. ]
  144. source_files = dockerfiles if dockerfiles is not None else {
  145. path: read_bytes_once(ROOT / path).decode("utf-8") for path in DOCKERFILES
  146. }
  147. for path, content in source_files.items():
  148. stages: set[str] = set()
  149. for number, line in enumerate(content.splitlines(), 1):
  150. match = re.match(r"\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?", line, re.IGNORECASE)
  151. if not match:
  152. continue
  153. reference, alias = match.groups()
  154. if reference in stages:
  155. records.append({"source": f"dockerfile:{path}:{number}", "reference": reference, "status": "INTERNAL_BUILD_STAGE", "gate": "PASS_LOCAL"})
  156. else:
  157. records.append(_image_record(f"dockerfile:{path}:{number}", reference))
  158. if alias:
  159. stages.add(alias)
  160. return records
  161. def _sha256(path: Path) -> str:
  162. return sha256_file_once(path)
  163. def _glob_regex(pattern: str) -> re.Pattern[str]:
  164. """Compile Docker's slash-aware wildcard subset used by this repository."""
  165. pieces: list[str] = []
  166. index = 0
  167. while index < len(pattern):
  168. if pattern.startswith("**/", index):
  169. pieces.append("(?:.*/)?")
  170. index += 3
  171. elif pattern.startswith("**", index):
  172. pieces.append(".*")
  173. index += 2
  174. elif pattern[index] == "*":
  175. pieces.append("[^/]*")
  176. index += 1
  177. elif pattern[index] == "?":
  178. pieces.append("[^/]")
  179. index += 1
  180. else:
  181. pieces.append(re.escape(pattern[index]))
  182. index += 1
  183. return re.compile("^" + "".join(pieces) + "(?:/.*)?$")
  184. @dataclass(frozen=True)
  185. class DockerIgnoreRule:
  186. pattern: str
  187. include: bool
  188. regex: re.Pattern[str]
  189. class DockerIgnoreMatcher:
  190. """Ordered `.dockerignore` matcher with audited rule source and negation."""
  191. def __init__(self, rules: tuple[DockerIgnoreRule, ...], source_sha256: str):
  192. self.rules = rules
  193. self.source_sha256 = source_sha256
  194. @classmethod
  195. def from_bytes(cls, content: bytes) -> "DockerIgnoreMatcher":
  196. rules: list[DockerIgnoreRule] = []
  197. for raw in content.decode("utf-8", errors="strict").splitlines():
  198. line = raw.strip()
  199. if not line or line.startswith("#"):
  200. continue
  201. include = line.startswith("!")
  202. pattern = line[1:] if include else line
  203. pattern = pattern.removeprefix("/").rstrip("/")
  204. if not pattern or pattern == ".":
  205. continue
  206. if any(character in pattern for character in "[]\\"):
  207. raise ValueError("unsupported dockerignore pattern; refusing approximation")
  208. if "/" not in pattern:
  209. pattern = "**/" + pattern
  210. rules.append(DockerIgnoreRule(pattern=pattern, include=include, regex=_glob_regex(pattern)))
  211. return cls(tuple(rules), hashlib.sha256(content).hexdigest())
  212. @classmethod
  213. def from_root(cls, root: Path) -> "DockerIgnoreMatcher":
  214. return cls.from_bytes(read_bytes_once(root / ".dockerignore", limit=MAX_DOCKERIGNORE_BYTES))
  215. def ignores(self, relative: str) -> bool:
  216. ignored = False
  217. for rule in self.rules:
  218. if rule.regex.fullmatch(relative):
  219. ignored = not rule.include
  220. return ignored
  221. def may_reinclude_descendant(self, relative: str) -> bool:
  222. """Conservatively retain an ignored directory when a negation exists."""
  223. # A later Dockerignore negation can re-include a child. We deliberately
  224. # over-approximate rather than incorrectly pruning an includable path.
  225. return any(rule.include for rule in self.rules)
  226. def _tree_digest(relative_root: str, *, root: Path | None = None, matcher: DockerIgnoreMatcher | None = None) -> str:
  227. root = ROOT if root is None else root
  228. context_matcher = matcher or (DockerIgnoreMatcher.from_root(root) if relative_root == "." else None)
  229. digest = hashlib.sha256(); digest.update(b"["); first_record = True
  230. entry_count = 0; total_bytes = 0
  231. def visit(directory: Path) -> None:
  232. nonlocal entry_count, total_bytes, first_record
  233. try:
  234. entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
  235. except OSError as error:
  236. raise ValueError("tree traversal failed") from error
  237. for entry in entries:
  238. path = Path(entry.path)
  239. relative = path.relative_to(root).as_posix()
  240. if len(relative.encode("utf-8")) > MAX_TREE_PATH_BYTES or len(Path(relative).parts) > MAX_TREE_DEPTH:
  241. raise ValueError("tree path boundary exceeded")
  242. entry_count += 1
  243. if entry_count > MAX_TREE_FILES:
  244. raise ValueError("tree resource boundary exceeded")
  245. try:
  246. metadata = entry.stat(follow_symlinks=False)
  247. except OSError as error:
  248. raise ValueError("tree entry changed during traversal") from error
  249. ignored = bool(context_matcher and context_matcher.ignores(relative))
  250. if stat.S_ISDIR(metadata.st_mode):
  251. if not (ignored and context_matcher and not context_matcher.may_reinclude_descendant(relative)):
  252. visit(path)
  253. continue
  254. derived = any(part == "__pycache__" for part in path.parts) or path.suffix == ".pyc"
  255. if not stat.S_ISREG(metadata.st_mode) or derived or ignored:
  256. continue
  257. total_bytes += metadata.st_size
  258. if total_bytes > MAX_TREE_TOTAL_BYTES:
  259. raise ValueError("tree resource boundary exceeded")
  260. record = json.dumps([relative, _sha256(path)], ensure_ascii=False, separators=(",", ":")).encode("utf-8")
  261. if not first_record: digest.update(b",")
  262. digest.update(record); first_record = False
  263. visit(root / relative_root)
  264. digest.update(b"]")
  265. return digest.hexdigest()
  266. def _input_census(command_env: dict[str, str]) -> str:
  267. matcher = DockerIgnoreMatcher.from_root(ROOT)
  268. values = {
  269. "trees": {root: _tree_digest(root, matcher=matcher if root == "." else None) for root in (*TREE_ROOTS, ".")},
  270. "files": {path: _sha256(ROOT / path) for path in FILE_PATHS},
  271. "compose": _sha256(ROOT / COMPOSE),
  272. "dockerignore": matcher.source_sha256,
  273. "closure": {path: (_tree_digest(path, matcher=matcher if path == "." else None) if (ROOT / path).is_dir() else _sha256(ROOT / path)) for _kind, path in COMPOSE_INPUTS},
  274. "git_status": _collect_git_status(command_env, _git_top_level(command_env))["sha256"],
  275. }
  276. return hashlib.sha256(json.dumps(values, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
  277. def _main_locked() -> None:
  278. command_env = _minimal_command_env()
  279. before_census = _input_census(command_env)
  280. git_top = _git_top_level(command_env)
  281. root_context_matcher = DockerIgnoreMatcher.from_root(ROOT)
  282. artifact_sets = [
  283. {"kind": "tree", "root": root, "sha256": _tree_digest(root), "excluded_derived_paths": ["__pycache__", "*.pyc"]}
  284. for root in TREE_ROOTS
  285. ]
  286. artifact_sets.extend(
  287. {"kind": "file", "path": path, "sha256": _sha256(ROOT / path)}
  288. for path in FILE_PATHS
  289. )
  290. closure_inputs = []
  291. for kind, path in COMPOSE_INPUTS:
  292. absolute = ROOT / path
  293. if absolute.is_dir():
  294. digest = _tree_digest(path, matcher=root_context_matcher if path == "." else None)
  295. else:
  296. digest = _sha256(absolute)
  297. closure_inputs.append({"kind": kind, "path": path, "sha256": digest})
  298. compose_services = _compose_services(command_env)
  299. image_references = _image_reference_closure()
  300. git_status = _collect_git_status(command_env, git_top)
  301. payload = {
  302. "schema_version": "1.0",
  303. "work_package": "P3-WP14",
  304. "release_identity": {
  305. "state": "UNCOMMITTED_WORKTREE_EVIDENCE",
  306. "signed": False,
  307. "signature_status": "TBD_EXTERNAL",
  308. "git_base_commit": _run_command(["git", "rev-parse", "HEAD"], command_env, text=True).stdout.strip(),
  309. "git_base_tree": _run_command(["git", "rev-parse", "HEAD^{tree}"], command_env, text=True).stdout.strip(),
  310. "worktree_clean": git_status["entry_count"] == 0,
  311. "git_status": git_status,
  312. "declaration": "This is a local, unsigned snapshot from a known dirty shared worktree. It is not a clean, signed, pushed, deployed, or enterprise-approved release.",
  313. },
  314. "controlled_artifact_sets": artifact_sets,
  315. "compose_input_closure": {
  316. "compose_file": COMPOSE,
  317. "compose_sha256": _sha256(ROOT / COMPOSE),
  318. "dockerignore": {"path": ".dockerignore", "sha256": root_context_matcher.source_sha256},
  319. "render_command": [COMPOSE_EXECUTABLE, "--env-file", "/dev/null", "-f", COMPOSE, "config", "--format", "json"],
  320. "parsed_services": compose_services,
  321. "inputs": closure_inputs,
  322. "unresolved_variable_mounts": [
  323. "${DATAOPS_EDGE_MTLS_CERT_DIR:-./edge-mtls-certs}",
  324. "${EDGE_GATEWAY_SIGNING_PRIVATE_KEY_FILE:-./edge-secrets/edge-gateway-signing.key}",
  325. ],
  326. },
  327. "image_reference_closure": {
  328. "items": image_references,
  329. "immutable_signed_deployability_status": "BLOCKED_EXTERNAL" if any(item["status"] == "UNRESOLVED_MUTABLE_TAG" for item in image_references) else "TBD_EXTERNAL",
  330. "boundary": "No registry pull was performed. Any tag or variable reference without @sha256 is mutable and blocks immutable/signed deployability acceptance.",
  331. },
  332. "excluded_dependent_evidence": [
  333. "docs/phase3/P3_WP14_RELEASE_MANIFEST.json",
  334. "docs/phase3/P3_WP14_RELEASE_TRACEABILITY.json",
  335. "docs/validation/P3_WP14_LOCAL_CONFIG_RENDER.json",
  336. "docs/validation/P3_WP14_LOCAL_VERIFICATION_LEDGER.json",
  337. "docs/validation/P3_WP14_LOCAL_VERIFICATION.log",
  338. ],
  339. "boundary": "Dependent evidence is digest-checked by the traceability contract but excluded here to avoid self-referential hashes. No secret, enterprise endpoint, or external signature is recorded.",
  340. }
  341. if before_census != _input_census(command_env):
  342. raise RuntimeError("manifest input closure changed during generation")
  343. atomic_write_bytes(OUTPUT, json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + b"\n")
  344. def main() -> None:
  345. with exclusive_lock(MANIFEST_LOCK):
  346. _main_locked()
  347. if __name__ == "__main__":
  348. main()