diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index accf1e3..217d122 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -99,6 +99,7 @@ The private installation directory contains: | `existing-proxy.json` | Exact upstream, trusted-source, header, and health contract for an operator-owned proxy | | `plan.json` | Latest desired-state diff and readiness findings | | `receipt.json` | Last successfully applied immutable identities | +| `infrastructure-capabilities.json` | Deterministic non-secret capability states, endpoint metadata, secret references, consumers, and resumable post-install tasks | | `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer | | `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases | | `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt | @@ -408,16 +409,24 @@ the supported topology and promotion path. ## Reconfiguration Semantics `installation.json` is desired state. `receipt.json` is the last successfully -applied state. `plan` compares their canonical hashes and service sets. +applied state. `plan` compares their canonical hashes, service sets, and +infrastructure capability projections. - Adding a managed component creates its service and persistent volume. - Removing a component removes its service container on apply. +- Replacing or removing a capability adds a review action that names the prior + and desired state/source plus declared module consumers. This does not claim + that the deployer can inspect module-owned database configuration; the + operator must review that inventory before apply. - Volumes are retained by default; deleting data requires a separate, deliberately destructive workflow. - Existing generated credentials are retained unless an explicit future rotate operation is requested. - Private configuration changes are represented by a keyed fingerprint in the plan and receipt; plaintext values are never copied there. +- Capability documents contain sanitized scheme/host/port metadata and stable + `env:` references only. Credential values and secret-bearing URLs remain in + `secrets.env` or module-owned credential envelopes. - Managed-to-external transitions require the new endpoint in the same operation. - Migrations run as a one-shot service before API/worker replacement. @@ -430,6 +439,12 @@ applied state. `plan` compares their canonical hashes and service sets. - Health must recover before a new receipt and applied-state snapshot are committed. +Compose mounts the capability document read-only into API and worker runtime +containers. The Kubernetes export projects the same document through a +dedicated ConfigMap and read-only file mount. Ops validates the bounded schema +before displaying configured, externally supplied, available-unconfigured, or +unavailable states and any pending post-install tasks. + Every apply operation is journalled before image pulls or runtime mutation. A failure before migration may restore a verified previous bundle. Once migration starts, recovery is forward-only unless an independently verified database diff --git a/tests/test_deployment_installer.py b/tests/test_deployment_installer.py index 0593505..03208a9 100644 --- a/tests/test_deployment_installer.py +++ b/tests/test_deployment_installer.py @@ -33,6 +33,10 @@ from govoplan_deploy.bundle import ( # noqa: E402 ) from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402 import govoplan_deploy.cli as deployment_cli # noqa: E402 +from govoplan_deploy.capabilities import ( # noqa: E402 + capability_change_impacts, + infrastructure_capability_document, +) from govoplan_deploy.cluster_evidence import ( # noqa: E402 collect_kubernetes_evidence, ) @@ -414,6 +418,27 @@ class DeploymentInstallerTests(unittest.TestCase): self.assertNotIn("db-secret", rendered) self.assertNotIn("redis-secret", rendered) self.assertNotIn("object-secret", rendered) + capability_config = next( + item + for item in manifest["items"] + if item["kind"] == "ConfigMap" + and item["metadata"]["name"].endswith("infrastructure-capabilities") + ) + capability_payload = json.loads( + capability_config["data"]["infrastructure-capabilities.json"] + ) + self.assertEqual(1, capability_payload["schema_version"]) + self.assertNotIn("db-secret", json.dumps(capability_payload)) + api_container = deployments["govoplan-cluster-api"]["spec"]["template"]["spec"]["containers"][0] + self.assertIn( + { + "name": "deployment-capabilities", + "mountPath": "/etc/govoplan/deployment/infrastructure-capabilities.json", + "subPath": "infrastructure-capabilities.json", + "readOnly": True, + }, + api_container["volumeMounts"], + ) self.assertNotIn("PersistentVolumeClaim", kinds) self.assertNotIn("StatefulSet", kinds) self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"]) @@ -723,6 +748,10 @@ class DeploymentInstallerTests(unittest.TestCase): compose["services"]["load-balancer"]["ports"], ) self.assertNotIn("ports", compose["services"]["web"]) + self.assertIn( + "./infrastructure-capabilities.json:/etc/govoplan/deployment/infrastructure-capabilities.json:ro", + compose["services"]["api"]["volumes"], + ) self.assertEqual(1, compose["services"]["api"]["scale"]) self.assertEqual(1, compose["services"]["web"]["scale"]) @@ -948,6 +977,63 @@ class DeploymentInstallerTests(unittest.TestCase): reconciled["GARAGE_RPC_SECRET"], ) + def test_infrastructure_capability_document_exposes_refs_not_secrets(self) -> None: + spec = default_spec( + installation_id="govoplan-shared", + postgres_mode="external", + redis_mode="external", + storage_mode="s3", + mail_mode="external-relay", + module_set="full", + ) + values = initial_secrets( + spec, + supplied={ + "DATABASE_URL": "postgresql+psycopg://user:database-secret@db.example.test/govoplan", + "REDIS_URL": "rediss://:redis-secret@redis.example.test/0", + "FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test", + "FILE_STORAGE_S3_REGION": "eu-test-1", + "FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key", + "FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret", + "FILE_STORAGE_S3_BUCKET": "govoplan", + }, + ) + + document = infrastructure_capability_document(spec, values) + rendered = json.dumps(document, sort_keys=True) + capabilities = {item["id"]: item for item in document["capabilities"]} + + self.assertNotIn("database-secret", rendered) + self.assertNotIn("redis-secret", rendered) + self.assertNotIn("object-secret", rendered) + self.assertNotIn("object-key", rendered) + self.assertEqual("externally_supplied", capabilities["database.postgresql"]["state"]) + self.assertEqual("db.example.test", capabilities["database.postgresql"]["endpoint"]["host"]) + self.assertEqual(["env:DATABASE_URL"], capabilities["database.postgresql"]["secret_refs"]) + self.assertEqual("available_unconfigured", capabilities["mail.smtp"]["state"]) + self.assertEqual("mail.smtp-profile", document["post_install_tasks"][0]["id"]) + + def test_capability_impact_detects_external_endpoint_rebinding(self) -> None: + spec = default_spec(postgres_mode="external", module_set="full") + previous = infrastructure_capability_document( + spec, + {"DATABASE_URL": "postgresql://user:old-secret@old-db.example.test/govoplan"}, + ) + desired = infrastructure_capability_document( + spec, + {"DATABASE_URL": "postgresql://user:new-secret@new-db.example.test/govoplan"}, + ) + + impacts = { + item.capability_id: item + for item in capability_change_impacts(previous, desired) + } + + self.assertEqual("reconfigure", impacts["database.postgresql"].action) + self.assertIn("changed endpoint binding", impacts["database.postgresql"].detail) + self.assertNotIn("old-secret", impacts["database.postgresql"].detail) + self.assertNotIn("new-secret", impacts["database.postgresql"].detail) + def test_replica_counts_drive_compose_and_load_balancer_discovery(self) -> None: spec = default_spec( storage_mode="garage", @@ -1077,10 +1163,14 @@ class DeploymentInstallerTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory: paths = bundle_paths(Path(directory)) paths.root.chmod(0o700) - first_spec = default_spec(mail_mode="test-mail") + first_spec = default_spec(mail_mode="test-mail", module_set="full") first_environment = initial_secrets(first_spec) write_env(paths.env, first_environment) first_plan = build_plan(first_spec, paths, include_host_checks=False) + first_capabilities = infrastructure_capability_document( + first_spec, + first_environment, + ) atomic_write( paths.receipt, canonical_json( @@ -1091,12 +1181,17 @@ class DeploymentInstallerTests(unittest.TestCase): first_plan.desired_environment_fingerprint ), "services": list(render_compose(first_spec)["services"]), + "infrastructure_capabilities": first_capabilities, } ), mode=0o600, ) - second_spec = default_spec(redis_mode="disabled", mail_mode="disabled") + second_spec = default_spec( + redis_mode="disabled", + mail_mode="disabled", + module_set="full", + ) write_env( paths.env, reconcile_runtime_environment(second_spec, first_environment), @@ -1112,6 +1207,20 @@ class DeploymentInstallerTests(unittest.TestCase): {"redis", "worker", "scheduler", "test-mail"}, removed, ) + impacts = { + item.capability_id: item + for item in second_plan.capability_impacts + } + self.assertEqual("remove", impacts["coordination.redis"].action) + self.assertEqual("remove", impacts["mail.smtp"].action) + self.assertIn("mail", impacts["mail.smtp"].dependent_modules) + self.assertTrue( + any( + check.id == "capability.change.mail.smtp" + and check.level == "warning" + for check in second_plan.checks + ) + ) def test_secret_change_is_planned_without_exposing_secret_values(self) -> None: with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory: @@ -1480,6 +1589,11 @@ class DeploymentInstallerTests(unittest.TestCase): receipt["listen"], ) self.assertNotIn("installer", receipt["services"]) + self.assertEqual( + 1, + receipt["infrastructure_capabilities"]["schema_version"], + ) + self.assertTrue((root / "infrastructure-capabilities.json").is_file()) def test_installation_root_symlink_is_rejected(self) -> None: if not hasattr(Path, "symlink_to"): diff --git a/tools/deployment/govoplan_deploy/bundle.py b/tools/deployment/govoplan_deploy/bundle.py index 32defb5..e7bb894 100644 --- a/tools/deployment/govoplan_deploy/bundle.py +++ b/tools/deployment/govoplan_deploy/bundle.py @@ -26,6 +26,7 @@ CADDY_CONFIG_FILENAME = "Caddyfile" EXISTING_PROXY_FILENAME = "existing-proxy.json" PLAN_FILENAME = "plan.json" RECEIPT_FILENAME = "receipt.json" +CAPABILITIES_FILENAME = "infrastructure-capabilities.json" MANIFEST_FILENAME = "distribution-manifest.json" KEYRING_FILENAME = "distribution-keyring.json" BACKUP_EVIDENCE_FILENAME = "backup-evidence.json" @@ -88,6 +89,7 @@ RUNTIME_ENV_KEYS = ( "DEV_BOOTSTRAP_ENABLED", "GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE", "GOVOPLAN_DEPLOYMENT_SPEC_PATH", + "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH", "FILE_STORAGE_BACKEND", "FILE_STORAGE_LOCAL_ROOT", "FILE_STORAGE_S3_ENDPOINT_URL", @@ -113,6 +115,7 @@ class BundlePaths: existing_proxy: Path plan: Path receipt: Path + capabilities: Path manifest: Path keyring: Path backup_evidence: Path @@ -137,6 +140,7 @@ def bundle_paths(root: Path) -> BundlePaths: existing_proxy=resolved / EXISTING_PROXY_FILENAME, plan=resolved / PLAN_FILENAME, receipt=resolved / RECEIPT_FILENAME, + capabilities=resolved / CAPABILITIES_FILENAME, manifest=resolved / MANIFEST_FILENAME, keyring=resolved / KEYRING_FILENAME, backup_evidence=resolved / BACKUP_EVIDENCE_FILENAME, @@ -296,6 +300,7 @@ def reconcile_runtime_environment( "DEV_AUTO_MIGRATE_ENABLED": "false", "DEV_BOOTSTRAP_ENABLED": "false", "GOVOPLAN_DEPLOYMENT_SPEC_PATH": "/etc/govoplan/deployment/installation.json", + "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH": "/etc/govoplan/deployment/infrastructure-capabilities.json", } ) if redis.mode == "disabled": @@ -370,7 +375,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]: deployment_mount = ( f"./{SPEC_FILENAME}:/etc/govoplan/deployment/installation.json:ro" ) - data_mounts = [deployment_mount] + capabilities_mount = ( + f"./{CAPABILITIES_FILENAME}:/etc/govoplan/deployment/infrastructure-capabilities.json:ro" + ) + data_mounts = [deployment_mount, capabilities_mount] if spec.components.storage.mode == "local": data_mounts.append("files-data:/var/lib/govoplan/files") diff --git a/tools/deployment/govoplan_deploy/capabilities.py b/tools/deployment/govoplan_deploy/capabilities.py new file mode 100644 index 0000000..13e4823 --- /dev/null +++ b/tools/deployment/govoplan_deploy/capabilities.py @@ -0,0 +1,489 @@ +"""Non-secret infrastructure capability projection and change impact.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Mapping +from urllib.parse import urlsplit + +from .model import InstallationSpec + + +CAPABILITY_DOCUMENT_SCHEMA_VERSION = 1 +CAPABILITY_STATES = frozenset( + { + "configured", + "available_unconfigured", + "externally_supplied", + "unavailable", + } +) + + +@dataclass(frozen=True, slots=True) +class InfrastructureCapability: + id: str + label: str + state: str + source: str + detail: str + endpoint: Mapping[str, object] + secret_refs: tuple[str, ...] + dependent_modules: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + value = asdict(self) + value["endpoint"] = dict(self.endpoint) + value["secret_refs"] = list(self.secret_refs) + value["dependent_modules"] = list(self.dependent_modules) + return value + + +@dataclass(frozen=True, slots=True) +class CapabilityChangeImpact: + capability_id: str + action: str + previous_state: str + desired_state: str + previous_source: str + desired_source: str + dependent_modules: tuple[str, ...] + detail: str + required_action: str + + def to_dict(self) -> dict[str, object]: + value = asdict(self) + value["dependent_modules"] = list(self.dependent_modules) + return value + + +def infrastructure_capability_document( + spec: InstallationSpec, + environment: Mapping[str, str], +) -> dict[str, object]: + """Project installer choices without copying credentials or secret URLs.""" + + capabilities = tuple( + sorted( + ( + _postgres_capability(spec, environment), + _redis_capability(spec, environment), + _mail_capability(spec), + _storage_capability(spec, environment), + _load_balancer_capability(spec), + _ingress_capability(spec), + ), + key=lambda item: item.id, + ) + ) + tasks = _post_install_tasks(spec, capabilities) + return { + "schema_version": CAPABILITY_DOCUMENT_SCHEMA_VERSION, + "installation_id": spec.installation_id, + "profile": spec.profile, + "capabilities": [item.to_dict() for item in capabilities], + "post_install_tasks": tasks, + } + + +def capability_change_impacts( + previous_document: object, + desired_document: Mapping[str, object], +) -> tuple[CapabilityChangeImpact, ...]: + previous = _capability_map(previous_document) + desired = _capability_map(desired_document) + if not previous: + return () + impacts: list[CapabilityChangeImpact] = [] + for capability_id in sorted(set(previous) | set(desired)): + before = previous.get(capability_id) + after = desired.get(capability_id) + if before is None or after is None: + continue + previous_state = str(before.get("state") or "unavailable") + desired_state = str(after.get("state") or "unavailable") + previous_source = str(before.get("source") or "unknown") + desired_source = str(after.get("source") or "unknown") + previous_endpoint = _endpoint_signature(before.get("endpoint")) + desired_endpoint = _endpoint_signature(after.get("endpoint")) + previous_secret_refs = tuple( + sorted(_string_items(before.get("secret_refs"))) + ) + desired_secret_refs = tuple( + sorted(_string_items(after.get("secret_refs"))) + ) + if ( + previous_state == desired_state + and previous_source == desired_source + and previous_endpoint == desired_endpoint + and previous_secret_refs == desired_secret_refs + ): + continue + action = ( + "remove" + if previous_state != "unavailable" and desired_state == "unavailable" + else "replace" + if previous_source != desired_source + else "reconfigure" + ) + dependents = tuple( + sorted( + { + *(_string_items(before.get("dependent_modules"))), + *(_string_items(after.get("dependent_modules"))), + } + ) + ) + dependent_label = ", ".join(dependents) or "no declared module consumers" + binding_change = _binding_change_label( + previous_endpoint, + desired_endpoint, + previous_secret_refs, + desired_secret_refs, + ) + impacts.append( + CapabilityChangeImpact( + capability_id=capability_id, + action=action, + previous_state=previous_state, + desired_state=desired_state, + previous_source=previous_source, + desired_source=desired_source, + dependent_modules=dependents, + detail=( + f"{capability_id} changes from {previous_state}/{previous_source} " + f"to {desired_state}/{desired_source}{binding_change}; " + f"declared consumers: {dependent_label}." + ), + required_action=( + "Review module-owned configuration and data migration or recovery evidence before apply." + ), + ) + ) + return tuple(impacts) + + +def _postgres_capability( + spec: InstallationSpec, + environment: Mapping[str, str], +) -> InfrastructureCapability: + managed = spec.components.postgres.mode == "managed" + endpoint = ( + {"scheme": "postgresql", "host": "postgres", "port": 5432} + if managed + else _redacted_endpoint(environment.get("DATABASE_URL", ""), default_port=5432) + ) + return InfrastructureCapability( + id="database.postgresql", + label="PostgreSQL database", + state="configured" if managed else "externally_supplied", + source="installer-managed" if managed else "operator-supplied", + detail=( + "The installer manages the database service." + if managed + else "The deployment binds an externally operated PostgreSQL service." + ), + endpoint=endpoint, + secret_refs=("env:POSTGRES_PASSWORD",) if managed else ("env:DATABASE_URL",), + dependent_modules=("core", *tuple(sorted(spec.enabled_modules))), + ) + + +def _redis_capability( + spec: InstallationSpec, + environment: Mapping[str, str], +) -> InfrastructureCapability: + mode = spec.components.redis.mode + consumers = _enabled_consumers( + spec, + { + "campaigns", + "dataflow", + "files", + "mail", + "notifications", + "scheduling", + "workflow_engine", + }, + include_core=True, + ) + if mode == "disabled": + return InfrastructureCapability( + id="coordination.redis", + label="Redis coordination and queues", + state="unavailable", + source="disabled", + detail="Distributed queues and coordination are disabled.", + endpoint={}, + secret_refs=(), + dependent_modules=consumers, + ) + managed = mode == "managed" + endpoint = ( + {"scheme": "redis", "host": "redis", "port": 6379} + if managed + else _redacted_endpoint(environment.get("REDIS_URL", ""), default_port=6379) + ) + return InfrastructureCapability( + id="coordination.redis", + label="Redis coordination and queues", + state="configured" if managed else "externally_supplied", + source="installer-managed" if managed else "operator-supplied", + detail=( + "The installer manages the Redis service." + if managed + else "The deployment binds an externally operated Redis service." + ), + endpoint=endpoint, + secret_refs=("env:REDIS_PASSWORD",) if managed else ("env:REDIS_URL",), + dependent_modules=consumers, + ) + + +def _mail_capability(spec: InstallationSpec) -> InfrastructureCapability: + mode = spec.components.mail.mode + consumers = _enabled_consumers( + spec, + {"campaigns", "mail", "notifications"}, + ) + if mode == "disabled": + return InfrastructureCapability( + id="mail.smtp", + label="SMTP delivery", + state="unavailable", + source="disabled", + detail="No SMTP infrastructure was selected.", + endpoint={}, + secret_refs=(), + dependent_modules=consumers, + ) + if mode == "test-mail": + return InfrastructureCapability( + id="mail.smtp", + label="SMTP delivery", + state="available_unconfigured", + source="installer-managed-test", + detail="GreenMail is reachable, but Mail still owns profile and credential configuration.", + endpoint={"scheme": "smtp", "host": "test-mail", "port": 3025}, + secret_refs=(), + dependent_modules=consumers, + ) + return InfrastructureCapability( + id="mail.smtp", + label="SMTP delivery", + state="available_unconfigured", + source="operator-supplied", + detail="An external relay was selected; Mail still needs a reviewed server and credential binding.", + endpoint={}, + secret_refs=(), + dependent_modules=consumers, + ) + + +def _storage_capability( + spec: InstallationSpec, + environment: Mapping[str, str], +) -> InfrastructureCapability: + mode = spec.components.storage.mode + consumers = _enabled_consumers( + spec, + {"campaigns", "files", "records", "templates"}, + ) + if mode == "local": + return InfrastructureCapability( + id="files.storage", + label="Managed file content storage", + state="configured", + source="host-local", + detail="Files use the installer-managed local persistent volume.", + endpoint={"kind": "filesystem", "reference": "volume:files-data"}, + secret_refs=(), + dependent_modules=consumers, + ) + if mode == "garage": + return InfrastructureCapability( + id="files.storage", + label="Managed file content storage", + state="configured", + source="installer-managed-garage", + detail="Files use the installer-managed single-node Garage service.", + endpoint={"scheme": "http", "host": "garage", "port": 3900}, + secret_refs=( + "env:FILE_STORAGE_S3_ACCESS_KEY_ID", + "env:FILE_STORAGE_S3_SECRET_ACCESS_KEY", + "env:GARAGE_RPC_SECRET", + ), + dependent_modules=consumers, + ) + return InfrastructureCapability( + id="files.storage", + label="Managed file content storage", + state="externally_supplied", + source="operator-supplied-s3", + detail="Files use an externally operated S3-compatible service.", + endpoint=_redacted_endpoint( + environment.get("FILE_STORAGE_S3_ENDPOINT_URL", ""), + default_port=443, + ), + secret_refs=( + "env:FILE_STORAGE_S3_ACCESS_KEY_ID", + "env:FILE_STORAGE_S3_SECRET_ACCESS_KEY", + ), + dependent_modules=consumers, + ) + + +def _load_balancer_capability(spec: InstallationSpec) -> InfrastructureCapability: + return InfrastructureCapability( + id="runtime.load_balancing", + label="Application load balancing", + state="configured", + source="installer-managed", + detail=( + f"HAProxy balances {spec.replicas.web} WebUI and {spec.replicas.api} API replica(s)." + ), + endpoint={"scheme": "http", "host": "load-balancer", "port": 8080}, + secret_refs=(), + dependent_modules=("core", "ops"), + ) + + +def _ingress_capability(spec: InstallationSpec) -> InfrastructureCapability: + mode = spec.ingress.mode + endpoint = _redacted_endpoint(spec.public_url, default_port=443) + if mode == "unconfigured": + state = "unavailable" + source = "unconfigured" + detail = "No supported public ingress boundary is configured." + elif mode == "existing-proxy": + state = "externally_supplied" + source = "operator-supplied-proxy" + detail = "An externally operated reverse proxy provides public ingress." + else: + state = "configured" + source = "installer-managed" if mode == "managed" else "host-local" + detail = "The installer has a bounded public ingress configuration." + return InfrastructureCapability( + id="network.ingress", + label="Public HTTP ingress", + state=state, + source=source, + detail=detail, + endpoint=endpoint, + secret_refs=(), + dependent_modules=("core", "ops"), + ) + + +def _post_install_tasks( + spec: InstallationSpec, + capabilities: tuple[InfrastructureCapability, ...], +) -> list[dict[str, object]]: + by_id = {item.id: item for item in capabilities} + tasks: list[dict[str, object]] = [] + mail = by_id["mail.smtp"] + if mail.state == "available_unconfigured" and "mail" in spec.enabled_modules: + tasks.append( + { + "id": "mail.smtp-profile", + "resume_key": f"{spec.installation_id}:mail.smtp-profile:v1", + "capability_id": mail.id, + "state": "pending", + "owner_module": "mail", + "summary": "Create or select a Mail SMTP server and credential envelope.", + "required_inputs": [ + "server endpoint", + "transport security policy", + "credential envelope reference when authentication is required", + ], + "secret_boundary": "credential-envelope-reference-only", + } + ) + ingress = by_id["network.ingress"] + if ingress.state == "unavailable": + tasks.append( + { + "id": "network.configure-ingress", + "resume_key": f"{spec.installation_id}:network.configure-ingress:v1", + "capability_id": ingress.id, + "state": "pending", + "owner_module": "ops", + "summary": "Select managed ingress or bind an existing reverse proxy.", + "required_inputs": ["public URL", "TLS and proxy trust boundary"], + "secret_boundary": "no-secret-material", + } + ) + return tasks + + +def _enabled_consumers( + spec: InstallationSpec, + candidates: set[str], + *, + include_core: bool = False, +) -> tuple[str, ...]: + consumers = candidates.intersection(spec.enabled_modules) + if include_core: + consumers.add("core") + return tuple(sorted(consumers)) + + +def _redacted_endpoint(value: str, *, default_port: int) -> dict[str, object]: + try: + parsed = urlsplit(value) + host = parsed.hostname + port = parsed.port or default_port + except ValueError: + return {"reference": "unresolved"} + if not parsed.scheme or not host: + return {"reference": "unresolved"} + return {"scheme": parsed.scheme, "host": host, "port": port} + + +def _capability_map(value: object) -> dict[str, Mapping[str, object]]: + if not isinstance(value, Mapping): + return {} + raw_items = value.get("capabilities") + if not isinstance(raw_items, list): + return {} + result: dict[str, Mapping[str, object]] = {} + for item in raw_items: + if not isinstance(item, Mapping): + continue + capability_id = str(item.get("id") or "").strip() + state = str(item.get("state") or "").strip() + if capability_id and state in CAPABILITY_STATES: + result[capability_id] = item + return result + + +def _string_items(value: object) -> tuple[str, ...]: + if not isinstance(value, list): + return () + return tuple(str(item).strip() for item in value if str(item).strip()) + + +def _endpoint_signature(value: object) -> tuple[tuple[str, str], ...]: + if not isinstance(value, Mapping): + return () + return tuple( + sorted( + (str(key), str(raw)) + for key, raw in value.items() + if isinstance(key, str) and isinstance(raw, (str, int, bool)) + ) + ) + + +def _binding_change_label( + previous_endpoint: tuple[tuple[str, str], ...], + desired_endpoint: tuple[tuple[str, str], ...], + previous_secret_refs: tuple[str, ...], + desired_secret_refs: tuple[str, ...], +) -> str: + changes: list[str] = [] + if previous_endpoint != desired_endpoint: + changes.append("endpoint binding") + if previous_secret_refs != desired_secret_refs: + changes.append("secret-reference binding") + return f" with changed {' and '.join(changes)}" if changes else "" diff --git a/tools/deployment/govoplan_deploy/cli.py b/tools/deployment/govoplan_deploy/cli.py index 6d13728..da07393 100644 --- a/tools/deployment/govoplan_deploy/cli.py +++ b/tools/deployment/govoplan_deploy/cli.py @@ -45,6 +45,7 @@ from .bundle import ( service_names, write_env, ) +from .capabilities import infrastructure_capability_document from .cluster_evidence import collect_kubernetes_evidence from .distribution import ( MAX_KEYRING_BYTES, @@ -1287,6 +1288,10 @@ def _deployment_receipt( "agent": "cli", "web_updates": False, }, + "infrastructure_capabilities": infrastructure_capability_document( + spec, + secrets, + ), } @@ -1464,6 +1469,11 @@ def _write_bundle( runtime_environment.update(_backup_runtime_environment(spec, paths)) atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600) write_env(paths.env, runtime_environment) + atomic_write( + paths.capabilities, + canonical_json(infrastructure_capability_document(spec, runtime_environment)), + mode=0o644, + ) atomic_write(paths.compose, canonical_json(render_compose(spec)), mode=0o600) atomic_write( paths.load_balancer_config, diff --git a/tools/deployment/govoplan_deploy/kubernetes.py b/tools/deployment/govoplan_deploy/kubernetes.py index b0e9279..f635f93 100644 --- a/tools/deployment/govoplan_deploy/kubernetes.py +++ b/tools/deployment/govoplan_deploy/kubernetes.py @@ -11,6 +11,7 @@ from typing import Any, Mapping from urllib.parse import urlsplit from .bundle import BACKUP_RUNTIME_ENV_KEYS +from .capabilities import infrastructure_capability_document from .model import InstallationSpec, image_is_digest_pinned @@ -96,6 +97,7 @@ def render_kubernetes( public_host = urlsplit(spec.public_url).hostname or "localhost" labels = {"app.kubernetes.io/name": "govoplan", "app.kubernetes.io/instance": name} config_name = f"{name}-runtime" + capabilities_config_name = f"{name}-infrastructure-capabilities" service_account = f"{name}-runtime" config = { key: str(environment[key]) @@ -142,6 +144,22 @@ def render_kubernetes( "metadata": {"name": config_name, "namespace": namespace, "labels": labels}, "data": dict(sorted(config.items())), }, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": capabilities_config_name, + "namespace": namespace, + "labels": labels, + }, + "data": { + "infrastructure-capabilities.json": json.dumps( + infrastructure_capability_document(spec, environment), + sort_keys=True, + separators=(",", ":"), + ) + }, + }, _deployment( name=f"{name}-api", namespace=namespace, @@ -161,6 +179,7 @@ def render_kubernetes( "--proxy-headers", ), config_name=config_name, + capabilities_config_name=capabilities_config_name, secret_name=secret_name, s3_ca_secret_name=s3_ca_secret_name, service_account=service_account, @@ -187,6 +206,7 @@ def render_kubernetes( image=spec.release.web_image, command=(), config_name=None, + capabilities_config_name=None, secret_name=None, s3_ca_secret_name=None, service_account=service_account, @@ -247,6 +267,7 @@ def render_kubernetes( "INFO", ), config_name=config_name, + capabilities_config_name=capabilities_config_name, secret_name=secret_name, s3_ca_secret_name=s3_ca_secret_name, service_account=service_account, @@ -303,6 +324,7 @@ def render_kubernetes( "/tmp/celerybeat-schedule", ), config_name=config_name, + capabilities_config_name=capabilities_config_name, secret_name=secret_name, s3_ca_secret_name=s3_ca_secret_name, service_account=service_account, @@ -652,6 +674,7 @@ def _deployment( image: str, command: tuple[str, ...], config_name: str | None, + capabilities_config_name: str | None, secret_name: str | None, s3_ca_secret_name: str | None, service_account: str, @@ -682,6 +705,13 @@ def _deployment( ) if secret_name: environment.extend(_secret_environment(secret_name)) + if capabilities_config_name: + environment.append( + { + "name": "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH", + "value": "/etc/govoplan/deployment/infrastructure-capabilities.json", + } + ) if s3_ca_secret_name: environment.append( {"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"} @@ -757,6 +787,29 @@ def _deployment( if s3_ca_secret_name: pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name)) container["volumeMounts"].append(_s3_ca_volume_mount()) + if capabilities_config_name: + pod_spec["volumes"].append( + { + "name": "deployment-capabilities", + "configMap": { + "name": capabilities_config_name, + "items": [ + { + "key": "infrastructure-capabilities.json", + "path": "infrastructure-capabilities.json", + } + ], + }, + } + ) + container["volumeMounts"].append( + { + "name": "deployment-capabilities", + "mountPath": "/etc/govoplan/deployment/infrastructure-capabilities.json", + "subPath": "infrastructure-capabilities.json", + "readOnly": True, + } + ) if config_name: pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}] if config_name and secret_name: diff --git a/tools/deployment/govoplan_deploy/planning.py b/tools/deployment/govoplan_deploy/planning.py index e964276..f38d879 100644 --- a/tools/deployment/govoplan_deploy/planning.py +++ b/tools/deployment/govoplan_deploy/planning.py @@ -35,6 +35,11 @@ from .bundle import ( render_existing_proxy_contract, service_names, ) +from .capabilities import ( + CapabilityChangeImpact, + capability_change_impacts, + infrastructure_capability_document, +) from .distribution import ( MAX_KEYRING_BYTES, MAX_MANIFEST_BYTES, @@ -83,6 +88,8 @@ class DeploymentPlan: desired_environment_fingerprint: str actions: tuple[PlanAction, ...] checks: tuple[Check, ...] + infrastructure_capabilities: Mapping[str, object] + capability_impacts: tuple[CapabilityChangeImpact, ...] @property def blocked(self) -> bool: @@ -97,6 +104,8 @@ class DeploymentPlan: "blocked": self.blocked, "actions": [action.to_dict() for action in self.actions], "checks": [check.to_dict() for check in self.checks], + "infrastructure_capabilities": dict(self.infrastructure_capabilities), + "capability_impacts": [item.to_dict() for item in self.capability_impacts], } @@ -122,6 +131,14 @@ def build_plan( spec_digest = digest_json(spec.to_dict()) compose_digest = digest_json(compose) environment_digest = environment_fingerprint(read_env(paths.env)) + infrastructure_capabilities = infrastructure_capability_document( + spec, + read_env(paths.env), + ) + capability_impacts = capability_change_impacts( + previous.get("infrastructure_capabilities"), + infrastructure_capabilities, + ) actions: list[PlanAction] = [] if not previous: @@ -166,12 +183,21 @@ def build_plan( "Remove the service container; retained volumes are not deleted.", ) ) + for impact in capability_impacts: + actions.append( + PlanAction( + "review", + f"capability:{impact.capability_id}", + impact.detail, + ) + ) if ( previous and previous_spec_digest == spec_digest and previous_compose_digest == compose_digest and previous_environment_fingerprint == environment_digest and previous_services == desired_services + and not capability_impacts ): actions.append( PlanAction( @@ -180,6 +206,15 @@ def build_plan( ) checks = list(static_checks(spec, paths)) + checks.extend( + Check( + id=f"capability.change.{impact.capability_id}", + level="warning", + message=impact.detail, + action=impact.required_action, + ) + for impact in capability_impacts + ) if include_host_checks: checks.extend(host_checks(spec, paths, command_runner=command_runner)) return DeploymentPlan( @@ -189,6 +224,8 @@ def build_plan( desired_environment_fingerprint=environment_digest, actions=tuple(actions), checks=tuple(checks), + infrastructure_capabilities=infrastructure_capabilities, + capability_impacts=capability_impacts, ) diff --git a/tools/deployment/govoplan_deploy/recovery.py b/tools/deployment/govoplan_deploy/recovery.py index 21cf2ce..bd43e42 100644 --- a/tools/deployment/govoplan_deploy/recovery.py +++ b/tools/deployment/govoplan_deploy/recovery.py @@ -33,6 +33,7 @@ _BUNDLE_FILES = ( "backup-keyring.json", "backup-verification.json", "receipt.json", + "infrastructure-capabilities.json", )