feat(deploy): project infrastructure capabilities
This commit is contained in:
@@ -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 |
|
| `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 |
|
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||||
| `receipt.json` | Last successfully applied immutable identities |
|
| `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-manifest.json` | Canonical signed runtime/image selection adopted by the installer |
|
||||||
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
|
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
|
||||||
| `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt |
|
| `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt |
|
||||||
@@ -408,16 +409,24 @@ the supported topology and promotion path.
|
|||||||
## Reconfiguration Semantics
|
## Reconfiguration Semantics
|
||||||
|
|
||||||
`installation.json` is desired state. `receipt.json` is the last successfully
|
`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.
|
- Adding a managed component creates its service and persistent volume.
|
||||||
- Removing a component removes its service container on apply.
|
- 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,
|
- Volumes are retained by default; deleting data requires a separate,
|
||||||
deliberately destructive workflow.
|
deliberately destructive workflow.
|
||||||
- Existing generated credentials are retained unless an explicit future rotate
|
- Existing generated credentials are retained unless an explicit future rotate
|
||||||
operation is requested.
|
operation is requested.
|
||||||
- Private configuration changes are represented by a keyed fingerprint in the
|
- Private configuration changes are represented by a keyed fingerprint in the
|
||||||
plan and receipt; plaintext values are never copied there.
|
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
|
- Managed-to-external transitions require the new endpoint in the same
|
||||||
operation.
|
operation.
|
||||||
- Migrations run as a one-shot service before API/worker replacement.
|
- 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
|
- Health must recover before a new receipt and applied-state snapshot are
|
||||||
committed.
|
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
|
Every apply operation is journalled before image pulls or runtime mutation. A
|
||||||
failure before migration may restore a verified previous bundle. Once migration
|
failure before migration may restore a verified previous bundle. Once migration
|
||||||
starts, recovery is forward-only unless an independently verified database
|
starts, recovery is forward-only unless an independently verified database
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ from govoplan_deploy.bundle import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
||||||
import govoplan_deploy.cli as deployment_cli # 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
|
from govoplan_deploy.cluster_evidence import ( # noqa: E402
|
||||||
collect_kubernetes_evidence,
|
collect_kubernetes_evidence,
|
||||||
)
|
)
|
||||||
@@ -414,6 +418,27 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertNotIn("db-secret", rendered)
|
self.assertNotIn("db-secret", rendered)
|
||||||
self.assertNotIn("redis-secret", rendered)
|
self.assertNotIn("redis-secret", rendered)
|
||||||
self.assertNotIn("object-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("PersistentVolumeClaim", kinds)
|
||||||
self.assertNotIn("StatefulSet", kinds)
|
self.assertNotIn("StatefulSet", kinds)
|
||||||
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
||||||
@@ -723,6 +748,10 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
compose["services"]["load-balancer"]["ports"],
|
compose["services"]["load-balancer"]["ports"],
|
||||||
)
|
)
|
||||||
self.assertNotIn("ports", compose["services"]["web"])
|
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"]["api"]["scale"])
|
||||||
self.assertEqual(1, compose["services"]["web"]["scale"])
|
self.assertEqual(1, compose["services"]["web"]["scale"])
|
||||||
|
|
||||||
@@ -948,6 +977,63 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
reconciled["GARAGE_RPC_SECRET"],
|
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:
|
def test_replica_counts_drive_compose_and_load_balancer_discovery(self) -> None:
|
||||||
spec = default_spec(
|
spec = default_spec(
|
||||||
storage_mode="garage",
|
storage_mode="garage",
|
||||||
@@ -1077,10 +1163,14 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
paths = bundle_paths(Path(directory))
|
paths = bundle_paths(Path(directory))
|
||||||
paths.root.chmod(0o700)
|
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)
|
first_environment = initial_secrets(first_spec)
|
||||||
write_env(paths.env, first_environment)
|
write_env(paths.env, first_environment)
|
||||||
first_plan = build_plan(first_spec, paths, include_host_checks=False)
|
first_plan = build_plan(first_spec, paths, include_host_checks=False)
|
||||||
|
first_capabilities = infrastructure_capability_document(
|
||||||
|
first_spec,
|
||||||
|
first_environment,
|
||||||
|
)
|
||||||
atomic_write(
|
atomic_write(
|
||||||
paths.receipt,
|
paths.receipt,
|
||||||
canonical_json(
|
canonical_json(
|
||||||
@@ -1091,12 +1181,17 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
first_plan.desired_environment_fingerprint
|
first_plan.desired_environment_fingerprint
|
||||||
),
|
),
|
||||||
"services": list(render_compose(first_spec)["services"]),
|
"services": list(render_compose(first_spec)["services"]),
|
||||||
|
"infrastructure_capabilities": first_capabilities,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
mode=0o600,
|
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(
|
write_env(
|
||||||
paths.env,
|
paths.env,
|
||||||
reconcile_runtime_environment(second_spec, first_environment),
|
reconcile_runtime_environment(second_spec, first_environment),
|
||||||
@@ -1112,6 +1207,20 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
{"redis", "worker", "scheduler", "test-mail"},
|
{"redis", "worker", "scheduler", "test-mail"},
|
||||||
removed,
|
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:
|
def test_secret_change_is_planned_without_exposing_secret_values(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
@@ -1480,6 +1589,11 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
receipt["listen"],
|
receipt["listen"],
|
||||||
)
|
)
|
||||||
self.assertNotIn("installer", receipt["services"])
|
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:
|
def test_installation_root_symlink_is_rejected(self) -> None:
|
||||||
if not hasattr(Path, "symlink_to"):
|
if not hasattr(Path, "symlink_to"):
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ CADDY_CONFIG_FILENAME = "Caddyfile"
|
|||||||
EXISTING_PROXY_FILENAME = "existing-proxy.json"
|
EXISTING_PROXY_FILENAME = "existing-proxy.json"
|
||||||
PLAN_FILENAME = "plan.json"
|
PLAN_FILENAME = "plan.json"
|
||||||
RECEIPT_FILENAME = "receipt.json"
|
RECEIPT_FILENAME = "receipt.json"
|
||||||
|
CAPABILITIES_FILENAME = "infrastructure-capabilities.json"
|
||||||
MANIFEST_FILENAME = "distribution-manifest.json"
|
MANIFEST_FILENAME = "distribution-manifest.json"
|
||||||
KEYRING_FILENAME = "distribution-keyring.json"
|
KEYRING_FILENAME = "distribution-keyring.json"
|
||||||
BACKUP_EVIDENCE_FILENAME = "backup-evidence.json"
|
BACKUP_EVIDENCE_FILENAME = "backup-evidence.json"
|
||||||
@@ -88,6 +89,7 @@ RUNTIME_ENV_KEYS = (
|
|||||||
"DEV_BOOTSTRAP_ENABLED",
|
"DEV_BOOTSTRAP_ENABLED",
|
||||||
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
|
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
|
||||||
"GOVOPLAN_DEPLOYMENT_SPEC_PATH",
|
"GOVOPLAN_DEPLOYMENT_SPEC_PATH",
|
||||||
|
"GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH",
|
||||||
"FILE_STORAGE_BACKEND",
|
"FILE_STORAGE_BACKEND",
|
||||||
"FILE_STORAGE_LOCAL_ROOT",
|
"FILE_STORAGE_LOCAL_ROOT",
|
||||||
"FILE_STORAGE_S3_ENDPOINT_URL",
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||||
@@ -113,6 +115,7 @@ class BundlePaths:
|
|||||||
existing_proxy: Path
|
existing_proxy: Path
|
||||||
plan: Path
|
plan: Path
|
||||||
receipt: Path
|
receipt: Path
|
||||||
|
capabilities: Path
|
||||||
manifest: Path
|
manifest: Path
|
||||||
keyring: Path
|
keyring: Path
|
||||||
backup_evidence: Path
|
backup_evidence: Path
|
||||||
@@ -137,6 +140,7 @@ def bundle_paths(root: Path) -> BundlePaths:
|
|||||||
existing_proxy=resolved / EXISTING_PROXY_FILENAME,
|
existing_proxy=resolved / EXISTING_PROXY_FILENAME,
|
||||||
plan=resolved / PLAN_FILENAME,
|
plan=resolved / PLAN_FILENAME,
|
||||||
receipt=resolved / RECEIPT_FILENAME,
|
receipt=resolved / RECEIPT_FILENAME,
|
||||||
|
capabilities=resolved / CAPABILITIES_FILENAME,
|
||||||
manifest=resolved / MANIFEST_FILENAME,
|
manifest=resolved / MANIFEST_FILENAME,
|
||||||
keyring=resolved / KEYRING_FILENAME,
|
keyring=resolved / KEYRING_FILENAME,
|
||||||
backup_evidence=resolved / BACKUP_EVIDENCE_FILENAME,
|
backup_evidence=resolved / BACKUP_EVIDENCE_FILENAME,
|
||||||
@@ -296,6 +300,7 @@ def reconcile_runtime_environment(
|
|||||||
"DEV_AUTO_MIGRATE_ENABLED": "false",
|
"DEV_AUTO_MIGRATE_ENABLED": "false",
|
||||||
"DEV_BOOTSTRAP_ENABLED": "false",
|
"DEV_BOOTSTRAP_ENABLED": "false",
|
||||||
"GOVOPLAN_DEPLOYMENT_SPEC_PATH": "/etc/govoplan/deployment/installation.json",
|
"GOVOPLAN_DEPLOYMENT_SPEC_PATH": "/etc/govoplan/deployment/installation.json",
|
||||||
|
"GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH": "/etc/govoplan/deployment/infrastructure-capabilities.json",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if redis.mode == "disabled":
|
if redis.mode == "disabled":
|
||||||
@@ -370,7 +375,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
|||||||
deployment_mount = (
|
deployment_mount = (
|
||||||
f"./{SPEC_FILENAME}:/etc/govoplan/deployment/installation.json:ro"
|
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":
|
if spec.components.storage.mode == "local":
|
||||||
data_mounts.append("files-data:/var/lib/govoplan/files")
|
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,
|
service_names,
|
||||||
write_env,
|
write_env,
|
||||||
)
|
)
|
||||||
|
from .capabilities import infrastructure_capability_document
|
||||||
from .cluster_evidence import collect_kubernetes_evidence
|
from .cluster_evidence import collect_kubernetes_evidence
|
||||||
from .distribution import (
|
from .distribution import (
|
||||||
MAX_KEYRING_BYTES,
|
MAX_KEYRING_BYTES,
|
||||||
@@ -1287,6 +1288,10 @@ def _deployment_receipt(
|
|||||||
"agent": "cli",
|
"agent": "cli",
|
||||||
"web_updates": False,
|
"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))
|
runtime_environment.update(_backup_runtime_environment(spec, paths))
|
||||||
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
atomic_write(paths.spec, canonical_json(spec.to_dict()), mode=0o600)
|
||||||
write_env(paths.env, runtime_environment)
|
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.compose, canonical_json(render_compose(spec)), mode=0o600)
|
||||||
atomic_write(
|
atomic_write(
|
||||||
paths.load_balancer_config,
|
paths.load_balancer_config,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Any, Mapping
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from .bundle import BACKUP_RUNTIME_ENV_KEYS
|
from .bundle import BACKUP_RUNTIME_ENV_KEYS
|
||||||
|
from .capabilities import infrastructure_capability_document
|
||||||
from .model import InstallationSpec, image_is_digest_pinned
|
from .model import InstallationSpec, image_is_digest_pinned
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ def render_kubernetes(
|
|||||||
public_host = urlsplit(spec.public_url).hostname or "localhost"
|
public_host = urlsplit(spec.public_url).hostname or "localhost"
|
||||||
labels = {"app.kubernetes.io/name": "govoplan", "app.kubernetes.io/instance": name}
|
labels = {"app.kubernetes.io/name": "govoplan", "app.kubernetes.io/instance": name}
|
||||||
config_name = f"{name}-runtime"
|
config_name = f"{name}-runtime"
|
||||||
|
capabilities_config_name = f"{name}-infrastructure-capabilities"
|
||||||
service_account = f"{name}-runtime"
|
service_account = f"{name}-runtime"
|
||||||
config = {
|
config = {
|
||||||
key: str(environment[key])
|
key: str(environment[key])
|
||||||
@@ -142,6 +144,22 @@ def render_kubernetes(
|
|||||||
"metadata": {"name": config_name, "namespace": namespace, "labels": labels},
|
"metadata": {"name": config_name, "namespace": namespace, "labels": labels},
|
||||||
"data": dict(sorted(config.items())),
|
"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(
|
_deployment(
|
||||||
name=f"{name}-api",
|
name=f"{name}-api",
|
||||||
namespace=namespace,
|
namespace=namespace,
|
||||||
@@ -161,6 +179,7 @@ def render_kubernetes(
|
|||||||
"--proxy-headers",
|
"--proxy-headers",
|
||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
|
capabilities_config_name=capabilities_config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
s3_ca_secret_name=s3_ca_secret_name,
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
@@ -187,6 +206,7 @@ def render_kubernetes(
|
|||||||
image=spec.release.web_image,
|
image=spec.release.web_image,
|
||||||
command=(),
|
command=(),
|
||||||
config_name=None,
|
config_name=None,
|
||||||
|
capabilities_config_name=None,
|
||||||
secret_name=None,
|
secret_name=None,
|
||||||
s3_ca_secret_name=None,
|
s3_ca_secret_name=None,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
@@ -247,6 +267,7 @@ def render_kubernetes(
|
|||||||
"INFO",
|
"INFO",
|
||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
|
capabilities_config_name=capabilities_config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
s3_ca_secret_name=s3_ca_secret_name,
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
@@ -303,6 +324,7 @@ def render_kubernetes(
|
|||||||
"/tmp/celerybeat-schedule",
|
"/tmp/celerybeat-schedule",
|
||||||
),
|
),
|
||||||
config_name=config_name,
|
config_name=config_name,
|
||||||
|
capabilities_config_name=capabilities_config_name,
|
||||||
secret_name=secret_name,
|
secret_name=secret_name,
|
||||||
s3_ca_secret_name=s3_ca_secret_name,
|
s3_ca_secret_name=s3_ca_secret_name,
|
||||||
service_account=service_account,
|
service_account=service_account,
|
||||||
@@ -652,6 +674,7 @@ def _deployment(
|
|||||||
image: str,
|
image: str,
|
||||||
command: tuple[str, ...],
|
command: tuple[str, ...],
|
||||||
config_name: str | None,
|
config_name: str | None,
|
||||||
|
capabilities_config_name: str | None,
|
||||||
secret_name: str | None,
|
secret_name: str | None,
|
||||||
s3_ca_secret_name: str | None,
|
s3_ca_secret_name: str | None,
|
||||||
service_account: str,
|
service_account: str,
|
||||||
@@ -682,6 +705,13 @@ def _deployment(
|
|||||||
)
|
)
|
||||||
if secret_name:
|
if secret_name:
|
||||||
environment.extend(_secret_environment(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:
|
if s3_ca_secret_name:
|
||||||
environment.append(
|
environment.append(
|
||||||
{"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"}
|
{"name": "AWS_CA_BUNDLE", "value": "/etc/govoplan/trust/s3-ca.crt"}
|
||||||
@@ -757,6 +787,29 @@ def _deployment(
|
|||||||
if s3_ca_secret_name:
|
if s3_ca_secret_name:
|
||||||
pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))
|
pod_spec["volumes"].append(_s3_ca_volume(s3_ca_secret_name))
|
||||||
container["volumeMounts"].append(_s3_ca_volume_mount())
|
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:
|
if config_name:
|
||||||
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
pod_spec["containers"][0]["envFrom"] = [{"configMapRef": {"name": config_name}}]
|
||||||
if config_name and secret_name:
|
if config_name and secret_name:
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ from .bundle import (
|
|||||||
render_existing_proxy_contract,
|
render_existing_proxy_contract,
|
||||||
service_names,
|
service_names,
|
||||||
)
|
)
|
||||||
|
from .capabilities import (
|
||||||
|
CapabilityChangeImpact,
|
||||||
|
capability_change_impacts,
|
||||||
|
infrastructure_capability_document,
|
||||||
|
)
|
||||||
from .distribution import (
|
from .distribution import (
|
||||||
MAX_KEYRING_BYTES,
|
MAX_KEYRING_BYTES,
|
||||||
MAX_MANIFEST_BYTES,
|
MAX_MANIFEST_BYTES,
|
||||||
@@ -83,6 +88,8 @@ class DeploymentPlan:
|
|||||||
desired_environment_fingerprint: str
|
desired_environment_fingerprint: str
|
||||||
actions: tuple[PlanAction, ...]
|
actions: tuple[PlanAction, ...]
|
||||||
checks: tuple[Check, ...]
|
checks: tuple[Check, ...]
|
||||||
|
infrastructure_capabilities: Mapping[str, object]
|
||||||
|
capability_impacts: tuple[CapabilityChangeImpact, ...]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def blocked(self) -> bool:
|
def blocked(self) -> bool:
|
||||||
@@ -97,6 +104,8 @@ class DeploymentPlan:
|
|||||||
"blocked": self.blocked,
|
"blocked": self.blocked,
|
||||||
"actions": [action.to_dict() for action in self.actions],
|
"actions": [action.to_dict() for action in self.actions],
|
||||||
"checks": [check.to_dict() for check in self.checks],
|
"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())
|
spec_digest = digest_json(spec.to_dict())
|
||||||
compose_digest = digest_json(compose)
|
compose_digest = digest_json(compose)
|
||||||
environment_digest = environment_fingerprint(read_env(paths.env))
|
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] = []
|
actions: list[PlanAction] = []
|
||||||
if not previous:
|
if not previous:
|
||||||
@@ -166,12 +183,21 @@ def build_plan(
|
|||||||
"Remove the service container; retained volumes are not deleted.",
|
"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 (
|
if (
|
||||||
previous
|
previous
|
||||||
and previous_spec_digest == spec_digest
|
and previous_spec_digest == spec_digest
|
||||||
and previous_compose_digest == compose_digest
|
and previous_compose_digest == compose_digest
|
||||||
and previous_environment_fingerprint == environment_digest
|
and previous_environment_fingerprint == environment_digest
|
||||||
and previous_services == desired_services
|
and previous_services == desired_services
|
||||||
|
and not capability_impacts
|
||||||
):
|
):
|
||||||
actions.append(
|
actions.append(
|
||||||
PlanAction(
|
PlanAction(
|
||||||
@@ -180,6 +206,15 @@ def build_plan(
|
|||||||
)
|
)
|
||||||
|
|
||||||
checks = list(static_checks(spec, paths))
|
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:
|
if include_host_checks:
|
||||||
checks.extend(host_checks(spec, paths, command_runner=command_runner))
|
checks.extend(host_checks(spec, paths, command_runner=command_runner))
|
||||||
return DeploymentPlan(
|
return DeploymentPlan(
|
||||||
@@ -189,6 +224,8 @@ def build_plan(
|
|||||||
desired_environment_fingerprint=environment_digest,
|
desired_environment_fingerprint=environment_digest,
|
||||||
actions=tuple(actions),
|
actions=tuple(actions),
|
||||||
checks=tuple(checks),
|
checks=tuple(checks),
|
||||||
|
infrastructure_capabilities=infrastructure_capabilities,
|
||||||
|
capability_impacts=capability_impacts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ _BUNDLE_FILES = (
|
|||||||
"backup-keyring.json",
|
"backup-keyring.json",
|
||||||
"backup-verification.json",
|
"backup-verification.json",
|
||||||
"receipt.json",
|
"receipt.json",
|
||||||
|
"infrastructure-capabilities.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user