feat(deploy): project infrastructure capabilities
This commit is contained in:
@@ -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 ""
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user