diff --git a/docs/deployment.md b/docs/deployment.md index 779c7a8..d1d86ed 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -202,6 +202,10 @@ Production images are published separately by the manual validated `platform_hosted_managed_image_lock`; workflow commit tags are build discovery aids and are not deployment inputs. The same lock also pins the third-party PostgreSQL runtime image used by the production Compose profile. +Render the non-secret production environment from that lock with +`scripts/render_hosted_managed_production_env.py`; this avoids manually +transcribing image digests and keeps the initial allowlist fixed to the +read-only canary operation. ## Local Compose Entry Point diff --git a/docs/hosted-managed-operations.md b/docs/hosted-managed-operations.md index b4b3579..28dca3d 100644 --- a/docs/hosted-managed-operations.md +++ b/docs/hosted-managed-operations.md @@ -303,7 +303,7 @@ Platform service, which continues to require its bearer token. All runtime images must be immutable digest refs: ```bash -export PLATFORM_MANAGED_OPERATION_IMAGE='ghcr.io/0al-spec/platform@sha256:' +export PLATFORM_MANAGED_OPERATION_IMAGE='ghcr.io/0al-spec/platform-hosted-managed@sha256:' export PLATFORM_MANAGED_OPERATION_POSTGRES_IMAGE='postgres@sha256:' export PLATFORM_MANAGED_OPERATION_INGRESS_IMAGE='ghcr.io/0al-spec/platform-hosted-managed-ingress@sha256:' export PLATFORM_MANAGED_OPERATION_ALLOWLIST=review_status_execute @@ -329,6 +329,27 @@ Set `PLATFORM_MANAGED_OPERATION_IMAGE`, values, not from mutable tags. The lock is public-safe evidence; it does not deploy, change the allowlist, or grant managed-operation authority. +Prefer rendering the non-secret deployment environment directly from that +validated lock instead of transcribing image refs by hand: + +```bash +sudo .venv/bin/python scripts/render_hosted_managed_production_env.py \ + --image-lock hosted-managed-image-lock.json \ + --output /etc/0al/hosted-managed-production.env \ + --artifact-root /srv/0al/specgraph \ + --state-dir /srv/0al/specspace-state \ + --backup-root /srv/0al/backups \ + --secret-root /srv/0al/secrets \ + --ingress-bind-ip 0.0.0.0 \ + --ingress-port 443 +``` + +The renderer validates the complete image lock, fixes the initial allowlist to +`review_status_execute`, rejects overlapping runtime/secret roots, writes the +environment atomically with mode `0440`, and never reads secret values. Run it +as root so the resulting file has the ownership expected by production +operations. Existing output is preserved unless `--overwrite` is explicit. + `deploy/hosted-managed/production.env.example` contains the complete non-secret environment inventory. It may be copied to a root-readable deployment env file, but the referenced secret values remain separate files and must never be added diff --git a/scripts/render_hosted_managed_production_env.py b/scripts/render_hosted_managed_production_env.py new file mode 100644 index 0000000..a1dac91 --- /dev/null +++ b/scripts/render_hosted_managed_production_env.py @@ -0,0 +1,201 @@ +"""Render a non-secret hosted managed production environment from an image lock.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Any + +try: + from scripts.validate_hosted_managed_image_lock import validate_image_lock +except ModuleNotFoundError: # Direct execution adds scripts/ rather than repo root. + from validate_hosted_managed_image_lock import validate_image_lock + + +SAFE_ABSOLUTE_PATH = re.compile(r"^/[A-Za-z0-9._/-]+$") +READ_ONLY_CANARY_OPERATION = "review_status_execute" +SECRET_FILENAMES = { + "PLATFORM_MANAGED_OPERATION_TOKEN_FILE": "service-token", + "PLATFORM_MANAGED_OPERATION_DB_PASSWORD_FILE": "database-password", + "PLATFORM_MANAGED_OPERATION_DATABASE_URL_FILE": "database-url", + "PLATFORM_MANAGED_OPERATION_GITHUB_TOKEN_FILE": "github-token", + "PLATFORM_MANAGED_OPERATION_TLS_CERTIFICATE_FILE": "tls-certificate.pem", + "PLATFORM_MANAGED_OPERATION_TLS_PRIVATE_KEY_FILE": "tls-private-key.pem", +} + + +class ProductionEnvRenderError(RuntimeError): + """The requested production environment is unsafe or incomplete.""" + + +def _load_image_lock(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProductionEnvRenderError("image lock is unavailable or invalid") from exc + if not isinstance(payload, dict): + raise ProductionEnvRenderError("image lock must be an object") + diagnostics = validate_image_lock(payload) + if diagnostics: + raise ProductionEnvRenderError( + "image lock failed validation: " + ", ".join(diagnostics) + ) + return payload + + +def _safe_root(value: str, *, label: str) -> Path: + if not SAFE_ABSOLUTE_PATH.fullmatch(value) or "//" in value: + raise ProductionEnvRenderError(f"{label} must be a normalized absolute path") + path = Path(value) + if path == Path("/") or any(part in {".", ".."} for part in path.parts): + raise ProductionEnvRenderError(f"{label} must be a bounded absolute path") + return path + + +def _roots_overlap(left: Path, right: Path) -> bool: + return left == right or left in right.parents or right in left.parents + + +def render_environment( + *, + image_lock: dict[str, Any], + artifact_root: str, + state_dir: str, + backup_root: str, + secret_root: str, + ingress_bind_ip: str, + ingress_port: int, +) -> tuple[str, dict[str, Any]]: + roots = { + "artifact_root": _safe_root(artifact_root, label="artifact root"), + "state_dir": _safe_root(state_dir, label="state directory"), + "backup_root": _safe_root(backup_root, label="backup root"), + "secret_root": _safe_root(secret_root, label="secret root"), + } + labels = list(roots) + for index, label in enumerate(labels): + for other_label in labels[index + 1 :]: + if _roots_overlap(roots[label], roots[other_label]): + raise ProductionEnvRenderError( + f"{label} and {other_label} must not overlap" + ) + try: + parsed_ip = ipaddress.ip_address(ingress_bind_ip) + except ValueError as exc: + raise ProductionEnvRenderError("ingress bind IP is invalid") from exc + if parsed_ip.version != 4: + raise ProductionEnvRenderError("ingress bind IP must be IPv4") + if not 1 <= ingress_port <= 65535: + raise ProductionEnvRenderError("ingress port is outside the valid range") + + images = image_lock["images"] + values = { + "PLATFORM_MANAGED_OPERATION_IMAGE": images["platform"]["image_ref"], + "PLATFORM_MANAGED_OPERATION_POSTGRES_IMAGE": images["postgresql"][ + "image_ref" + ], + "PLATFORM_MANAGED_OPERATION_INGRESS_IMAGE": images["ingress"]["image_ref"], + "PLATFORM_MANAGED_OPERATION_ALLOWLIST": READ_ONLY_CANARY_OPERATION, + "PLATFORM_MANAGED_OPERATION_ARTIFACT_ROOT": str(roots["artifact_root"]), + "PLATFORM_MANAGED_OPERATION_STATE_DIR": str(roots["state_dir"]), + "PLATFORM_MANAGED_OPERATION_BACKUP_ROOT": str(roots["backup_root"]), + "PLATFORM_MANAGED_OPERATION_INGRESS_BIND_IP": str(parsed_ip), + "PLATFORM_MANAGED_OPERATION_INGRESS_PORT": str(ingress_port), + } + values.update( + { + key: str(roots["secret_root"] / filename) + for key, filename in SECRET_FILENAMES.items() + } + ) + rendered = "\n".join(f"{key}={value}" for key, value in values.items()) + "\n" + report = { + "artifact_kind": "platform_hosted_managed_production_env_render_report", + "contract_ref": "platform.hosted-managed.production-env.v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "ok": True, + "summary": { + "status": "production_env_rendered", + "source_commit": image_lock["source_commit"], + "image_count": 3, + "enabled_operation_ids": [READ_ONLY_CANARY_OPERATION], + "secret_ref_count": len(SECRET_FILENAMES), + "environment_sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(), + }, + "privacy_boundary": { + "public_safe": True, + "includes_secret_values": False, + "includes_secret_paths": False, + "includes_local_paths": False, + }, + "authority_boundary": { + "may_deploy_production": False, + "may_create_secrets": False, + "may_execute_platform": False, + "may_enqueue_operations": False, + }, + } + return rendered, report + + +def _write_atomic(path: Path, content: str, *, overwrite: bool) -> None: + if path.exists() and not overwrite: + raise ProductionEnvRenderError("output already exists; use --overwrite") + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(0o440) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image-lock", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--artifact-root", default="/srv/0al/specgraph") + parser.add_argument("--state-dir", default="/srv/0al/specspace-state") + parser.add_argument("--backup-root", default="/srv/0al/backups") + parser.add_argument("--secret-root", default="/srv/0al/secrets") + parser.add_argument("--ingress-bind-ip", default="0.0.0.0") + parser.add_argument("--ingress-port", type=int, default=443) + parser.add_argument("--overwrite", action="store_true") + args = parser.parse_args(argv) + try: + image_lock = _load_image_lock(Path(args.image_lock)) + rendered, report = render_environment( + image_lock=image_lock, + artifact_root=args.artifact_root, + state_dir=args.state_dir, + backup_root=args.backup_root, + secret_root=args.secret_root, + ingress_bind_ip=args.ingress_bind_ip, + ingress_port=args.ingress_port, + ) + _write_atomic(Path(args.output), rendered, overwrite=args.overwrite) + except ProductionEnvRenderError as exc: + report = { + "artifact_kind": "platform_hosted_managed_production_env_render_report", + "ok": False, + "summary": {"status": "blocked"}, + "diagnostics": [str(exc)], + } + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report.get("ok") is True else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_hosted_managed_production_env.py b/tests/test_hosted_managed_production_env.py new file mode 100644 index 0000000..854433f --- /dev/null +++ b/tests/test_hosted_managed_production_env.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import io +import json +from pathlib import Path +import tempfile +import unittest + +from scripts import render_hosted_managed_production_env as renderer + + +def image_lock() -> dict: + digest = "a" * 64 + return { + "artifact_kind": "platform_hosted_managed_image_lock", + "schema_version": 1, + "generated_at": "2026-07-13T00:00:00+00:00", + "source_commit": "b" * 40, + "platforms": ["linux/amd64", "linux/arm64"], + "images": { + "platform": { + "image_ref": f"ghcr.io/0al-spec/platform-hosted-managed@sha256:{digest}" + }, + "postgresql": {"image_ref": f"postgres@sha256:{digest}"}, + "ingress": { + "image_ref": f"ghcr.io/0al-spec/platform-hosted-managed-ingress@sha256:{digest}", + "base_image_ref": f"caddy@sha256:{digest}", + "upstream_file_capability_removed": True, + }, + }, + "supply_chain": {"provenance_attestation": True, "sbom_attestation": True}, + "privacy_boundary": {"public_safe": True, "includes_secrets": False}, + "authority_boundary": {"may_deploy_production": False}, + } + + +class HostedManagedProductionEnvTests(unittest.TestCase): + def test_runbook_uses_renderer_and_current_platform_image_repository(self) -> None: + runbook = ( + Path(__file__).resolve().parents[1] + / "docs" + / "hosted-managed-operations.md" + ).read_text(encoding="utf-8") + self.assertIn("render_hosted_managed_production_env.py", runbook) + self.assertIn("ghcr.io/0al-spec/platform-hosted-managed@sha256:", runbook) + self.assertNotIn("ghcr.io/0al-spec/platform@sha256:", runbook) + + def test_renderer_uses_validated_lock_and_read_only_canary_scope(self) -> None: + rendered, report = renderer.render_environment( + image_lock=image_lock(), + artifact_root="/srv/0al/specgraph", + state_dir="/srv/0al/specspace-state", + backup_root="/srv/0al/backups", + secret_root="/srv/0al/secrets", + ingress_bind_ip="203.0.113.10", + ingress_port=443, + ) + self.assertIn( + "PLATFORM_MANAGED_OPERATION_ALLOWLIST=review_status_execute\n", rendered + ) + self.assertIn("PLATFORM_MANAGED_OPERATION_POSTGRES_IMAGE=postgres@sha256:", rendered) + self.assertIn( + "PLATFORM_MANAGED_OPERATION_TOKEN_FILE=/srv/0al/secrets/service-token", + rendered, + ) + self.assertNotIn("token-value", rendered) + self.assertEqual(report["summary"]["enabled_operation_ids"], ["review_status_execute"]) + self.assertFalse(report["authority_boundary"]["may_deploy_production"]) + + def test_renderer_rejects_overlapping_roots_and_invalid_bind_address(self) -> None: + arguments = { + "image_lock": image_lock(), + "artifact_root": "/srv/0al/specgraph", + "state_dir": "/srv/0al/specgraph/state", + "backup_root": "/srv/0al/backups", + "secret_root": "/srv/0al/secrets", + "ingress_bind_ip": "managed.example.org", + "ingress_port": 443, + } + with self.assertRaisesRegex(renderer.ProductionEnvRenderError, "must not overlap"): + renderer.render_environment(**arguments) + arguments["state_dir"] = "/srv/0al/specspace-state" + with self.assertRaisesRegex(renderer.ProductionEnvRenderError, "bind IP is invalid"): + renderer.render_environment(**arguments) + + def test_invalid_lock_does_not_replace_existing_environment(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + lock_path = root / "image-lock.json" + lock_path.write_text(json.dumps({"artifact_kind": "invalid"}), encoding="utf-8") + output = root / "production.env" + output.write_text("existing=true\n", encoding="utf-8") + with redirect_stdout(io.StringIO()): + result = renderer.main( + [ + "--image-lock", + str(lock_path), + "--output", + str(output), + "--overwrite", + ] + ) + self.assertEqual(result, 1) + self.assertEqual(output.read_text(encoding="utf-8"), "existing=true\n") + + def test_atomic_output_is_group_read_only(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output = Path(temp_dir) / "production.env" + renderer._write_atomic(output, "KEY=value\n", overwrite=False) + self.assertEqual(output.stat().st_mode & 0o777, 0o440) + with self.assertRaisesRegex(renderer.ProductionEnvRenderError, "already exists"): + renderer._write_atomic(output, "OTHER=value\n", overwrite=False) + + +if __name__ == "__main__": + unittest.main()