feat(deploy): project infrastructure capabilities
Dependency Audit / dependency-audit (push) Successful in 1m44s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m43s

This commit is contained in:
2026-08-07 01:59:45 +02:00
parent dce725636d
commit 612a44bc8e
8 changed files with 731 additions and 4 deletions
+9 -1
View File
@@ -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")
@@ -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 ""
+10
View File
@@ -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,
@@ -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:
@@ -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,
)
@@ -33,6 +33,7 @@ _BUNDLE_FILES = (
"backup-keyring.json",
"backup-verification.json",
"receipt.json",
"infrastructure-capabilities.json",
)