validate_p3_wp14_local_configs.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Validate two non-secret P3-WP14 snapshots through isolated Compose JSON renders.
  2. Every snapshot is read once as bytes and decoded from that same read. Its two
  3. runtime keys are the entire subprocess configuration (apart from ``PATH``), so
  4. the render cannot inherit parent DataOps configuration. The script records
  5. only digests and consumed non-secret label/port facts; it never starts Compose.
  6. """
  7. from __future__ import annotations
  8. import hashlib
  9. import json
  10. import os
  11. import re
  12. import subprocess
  13. import unicodedata
  14. from datetime import datetime, timezone
  15. from pathlib import Path
  16. from p3_wp14_secure_io import atomic_write_bytes, exclusive_lock, read_bytes_once, read_json_once
  17. ROOT = Path(__file__).resolve().parents[1]
  18. COMMAND_HOME = ROOT / "work/p3_wp14_config_command_home"
  19. COMPOSE_EXECUTABLE = "/usr/local/bin/docker-compose"
  20. CONFIGS = (
  21. "docs/validation/P3_WP14_LOCAL_CONFIG_A.json",
  22. "docs/validation/P3_WP14_LOCAL_CONFIG_B.json",
  23. )
  24. COMPARISON = ROOT / "docs/validation/P3_WP14_LOCAL_CONFIG_COMPARISON.json"
  25. RENDER_RECORD = ROOT / "docs/validation/P3_WP14_LOCAL_CONFIG_RENDER.json"
  26. LOCK = ROOT / "work/p3_wp14_config_lock_scope/lock"
  27. RENDER_COMMAND = [COMPOSE_EXECUTABLE, "--env-file", "/dev/null", "-f", "deploy/docker/docker-compose.yml", "config", "--format", "json"]
  28. ALLOWED_DIFFERENCES = {
  29. "/environment/label",
  30. "/runtime/DATAOPS_LOCAL_ENV_LABEL",
  31. "/runtime/BACKEND_PORT",
  32. }
  33. def _canonical_bytes(value: object) -> bytes:
  34. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
  35. def _flatten(value: object, prefix: str = "") -> dict[str, object]:
  36. if isinstance(value, dict):
  37. flattened: dict[str, object] = {}
  38. for key, child in value.items():
  39. flattened.update(_flatten(child, f"{prefix}/{key}"))
  40. return flattened
  41. if isinstance(value, list):
  42. flattened = {}
  43. for index, child in enumerate(value):
  44. flattened.update(_flatten(child, f"{prefix}/{index}"))
  45. return flattened
  46. return {prefix: value}
  47. def _assert_render_diff(actual: set[str], allowed: set[str]) -> None:
  48. if actual != allowed:
  49. raise ValueError(f"render difference outside exact allowlist: {sorted(actual)}")
  50. def _ascii(value: object, *, name: str, maximum: int = 80) -> str:
  51. if not isinstance(value, str) or not value or len(value) > maximum or not value.isascii() or unicodedata.normalize("NFKC", value) != value:
  52. raise ValueError(f"invalid {name}")
  53. return value
  54. def _validate_snapshot(snapshot: object) -> None:
  55. if not isinstance(snapshot, dict) or set(snapshot) != {"schema_version", "environment", "runtime", "compose"}:
  56. raise ValueError("snapshot schema is not closed")
  57. if snapshot["schema_version"] != "1.0":
  58. raise ValueError("unsupported snapshot schema")
  59. environment = snapshot["environment"]
  60. runtime = snapshot["runtime"]
  61. compose = snapshot["compose"]
  62. if not isinstance(environment, dict) or set(environment) != {"label", "is_enterprise"} or environment["is_enterprise"] is not False:
  63. raise ValueError("snapshot must be an explicitly non-enterprise configuration")
  64. if not isinstance(runtime, dict) or set(runtime) != {"DATAOPS_LOCAL_ENV_LABEL", "BACKEND_PORT"}:
  65. raise ValueError("runtime schema is not closed")
  66. if not isinstance(compose, dict) or set(compose) != {"file", "render_command"}:
  67. raise ValueError("compose schema is not closed")
  68. if compose["file"] != "deploy/docker/docker-compose.yml" or compose["render_command"] != RENDER_COMMAND:
  69. raise ValueError("unexpected compose render contract")
  70. _ascii(environment["label"], name="environment label")
  71. label = _ascii(runtime["DATAOPS_LOCAL_ENV_LABEL"], name="runtime label")
  72. if label != environment["label"] or not re.fullmatch(r"LOCAL_ISOLATED_[AB]", label):
  73. raise ValueError("invalid local environment label")
  74. port = _ascii(runtime["BACKEND_PORT"], name="backend port", maximum=5)
  75. if not port.isdecimal() or not 1 <= int(port) <= 65535:
  76. raise ValueError("invalid backend port")
  77. def _render(snapshot: dict[str, object]) -> tuple[str, dict[str, object], dict[str, object]]:
  78. runtime = snapshot["runtime"]
  79. assert isinstance(runtime, dict)
  80. isolated_env = {
  81. "PATH": os.environ.get("PATH", ""),
  82. "HOME": str(COMMAND_HOME),
  83. "DOCKER_CONFIG": str(COMMAND_HOME / "docker"),
  84. "DATAOPS_LOCAL_ENV_LABEL": str(runtime["DATAOPS_LOCAL_ENV_LABEL"]),
  85. "BACKEND_PORT": str(runtime["BACKEND_PORT"]),
  86. }
  87. completed = subprocess.run(
  88. RENDER_COMMAND,
  89. cwd=ROOT,
  90. env=isolated_env,
  91. text=True,
  92. capture_output=True,
  93. check=False,
  94. timeout=30,
  95. )
  96. rendered = json.loads(completed.stdout) if completed.returncode == 0 else {}
  97. services = rendered.get("services", {})
  98. backend = services.get("backend", {}) if isinstance(services, dict) else {}
  99. labels = backend.get("labels", {}) if isinstance(backend, dict) else {}
  100. ports = backend.get("ports", []) if isinstance(backend, dict) else []
  101. published_ports = {str(port.get("published")) for port in ports if isinstance(port, dict)}
  102. label = str(snapshot["environment"]["label"])
  103. return label, {
  104. "exit_code": completed.returncode,
  105. "status": "PASS" if completed.returncode == 0 else "FAIL",
  106. "raw_stdout_sha256": hashlib.sha256(completed.stdout.encode("utf-8")).hexdigest(),
  107. "stderr_sha256": hashlib.sha256(completed.stderr.encode("utf-8")).hexdigest(),
  108. "canonical_stdout_sha256": hashlib.sha256(_canonical_bytes(rendered)).hexdigest(),
  109. "services": sorted(services) if isinstance(services, dict) else [],
  110. "consumed_label": labels.get("com.dataops.local_env") if isinstance(labels, dict) else None,
  111. "consumed_backend_port": str(runtime["BACKEND_PORT"]) if str(runtime["BACKEND_PORT"]) in published_ports else None,
  112. }, _flatten(rendered)
  113. def _main_locked() -> int:
  114. raw = {path: read_bytes_once(ROOT / path, limit=8192) for path in CONFIGS}
  115. snapshots = {path: json.loads(content.decode("utf-8")) for path, content in raw.items()}
  116. for snapshot in snapshots.values():
  117. _validate_snapshot(snapshot)
  118. labels = {snapshot["environment"]["label"] for snapshot in snapshots.values()}
  119. if len(labels) != 2:
  120. raise ValueError("isolated configurations must use distinct labels")
  121. first, second = (snapshots[path] for path in CONFIGS)
  122. differences = {
  123. path
  124. for path in set(_flatten(first)) | set(_flatten(second))
  125. if _flatten(first).get(path) != _flatten(second).get(path)
  126. }
  127. if differences != ALLOWED_DIFFERENCES:
  128. raise ValueError(f"configuration difference outside allowlist: {sorted(differences)}")
  129. snapshot_sha256 = {path: hashlib.sha256(content).hexdigest() for path, content in raw.items()}
  130. comparison = read_json_once(COMPARISON, limit=8192)
  131. if not isinstance(comparison, dict):
  132. raise ValueError("invalid comparison")
  133. comparison["snapshot_sha256"] = snapshot_sha256
  134. atomic_write_bytes(COMPARISON, _canonical_bytes(comparison) + b"\n")
  135. rendered_pairs = [_render(snapshot) for snapshot in snapshots.values()]
  136. renders = {label: item for label, item, _ in rendered_pairs}
  137. first_render, second_render = rendered_pairs[0][2], rendered_pairs[1][2]
  138. render_differences = {
  139. path for path in set(first_render) | set(second_render)
  140. if first_render.get(path) != second_render.get(path)
  141. }
  142. allowed_render_differences = {
  143. "/services/backend/labels/com.dataops.local_env",
  144. "/services/backend/ports/0/published",
  145. }
  146. _assert_render_diff(render_differences, allowed_render_differences)
  147. service_sets = {tuple(item["services"]) for item in renders.values()}
  148. renders_consumed = all(
  149. item["exit_code"] == 0
  150. and item["consumed_label"] == label
  151. and item["consumed_backend_port"] is not None
  152. for label, item in renders.items()
  153. )
  154. exit_code = 0 if len(service_sets) == 1 and renders_consumed else 1
  155. record = {
  156. "schema_version": "1.0",
  157. "run_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
  158. "command_id": "local_config_compose_render",
  159. "render_command": RENDER_COMMAND,
  160. "exit_code": exit_code,
  161. "status": "PASS" if exit_code == 0 else "FAIL",
  162. "snapshot_sha256": snapshot_sha256,
  163. "allowed_difference_paths": sorted(ALLOWED_DIFFERENCES),
  164. "renders": renders,
  165. "render_difference_paths": sorted(render_differences),
  166. "compose_input_sha256": hashlib.sha256(read_bytes_once(ROOT / "deploy/docker/docker-compose.yml")).hexdigest(),
  167. "boundary": "Each snapshot was passed through an isolated subprocess environment for a local Compose JSON render only; no container was started and no enterprise system was contacted.",
  168. }
  169. atomic_write_bytes(RENDER_RECORD, _canonical_bytes(record) + b"\n")
  170. return exit_code
  171. def main() -> int:
  172. with exclusive_lock(LOCK):
  173. atomic_write_bytes(RENDER_RECORD, _canonical_bytes({"schema_version": "1.0", "status": "RUNNING", "exit_code": None, "invalidates_prior_pass": True}) + b"\n")
  174. try:
  175. return _main_locked()
  176. except (subprocess.TimeoutExpired, Exception) as error:
  177. atomic_write_bytes(RENDER_RECORD, _canonical_bytes({"schema_version": "1.0", "status": "FAILED", "exit_code": 1, "invalidates_prior_pass": True, "error_type": type(error).__name__}) + b"\n")
  178. return 1
  179. if __name__ == "__main__":
  180. raise SystemExit(main())