feat: expose typed deployment capability receipts
This commit is contained in:
@@ -159,7 +159,36 @@ The initial implementation includes provider-neutral orchestration helpers:
|
|||||||
|
|
||||||
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
The first concrete provider is `govoplan_access.backend.configuration_provider`.
|
||||||
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
It supports access-owned `roles`, `groups`, and `group_role_assignments`
|
||||||
fragments and applies them idempotently.
|
fragments and applies them idempotently. Mail and Files also register providers
|
||||||
|
for deployment configuration: Mail owns receipt-bound SMTP profiles and Files
|
||||||
|
validates the deployment-owned managed-storage binding.
|
||||||
|
|
||||||
|
### Deployment capability receipt
|
||||||
|
|
||||||
|
The installer mounts a bounded, non-secret infrastructure receipt at the path
|
||||||
|
named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document
|
||||||
|
once for configuration-package context and exposes typed capability and
|
||||||
|
post-install-task records to providers. Invalid receipts fail closed. Endpoint
|
||||||
|
metadata is sanitized, and secret fields may cross this boundary only as
|
||||||
|
`env:VARIABLE_NAME` references.
|
||||||
|
|
||||||
|
Feature providers remain responsible for their own semantics:
|
||||||
|
|
||||||
|
- Mail can derive host and port from `mail.smtp`, collect missing non-secret
|
||||||
|
transport fields, and bind an existing credential-envelope id. It never
|
||||||
|
accepts or exports a username, password, token, or decrypted credential.
|
||||||
|
- Files compares `files.storage` with the effective runtime backend, endpoint,
|
||||||
|
trust marker, bucket, and presence of referenced environment secrets. Storage
|
||||||
|
remains deployment-owned, so the provider reports `skip` when they agree and
|
||||||
|
blocks drift instead of rewriting process environment or storage credentials.
|
||||||
|
- A system-scoped Mail profile requires system configuration authority. Tenant
|
||||||
|
scope is the conservative default.
|
||||||
|
- Existing Mail configuration is preserved unless a reviewed fragment
|
||||||
|
explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is
|
||||||
|
a no-op.
|
||||||
|
|
||||||
|
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||||
|
parser with different validation or secret-handling rules.
|
||||||
|
|
||||||
The admin wizard backend starts with these routes:
|
The admin wizard backend starts with these routes:
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ from govoplan_core.core.external_references import (
|
|||||||
SourceAuthorityMode,
|
SourceAuthorityMode,
|
||||||
integration_maturity_rank,
|
integration_maturity_rank,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceipt,
|
||||||
|
)
|
||||||
from govoplan_core.security.http_fetch import fetch_http_text
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
@@ -441,6 +444,9 @@ class ConfigurationPreflightContext:
|
|||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
dry_run: bool = True
|
dry_run: bool = True
|
||||||
|
operator_scopes: frozenset[str] = frozenset()
|
||||||
|
infrastructure_receipt: InfrastructureCapabilityReceipt | None = None
|
||||||
|
infrastructure_receipt_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -586,11 +592,14 @@ def apply_configuration_package(
|
|||||||
apply_context = ConfigurationPreflightContext(
|
apply_context = ConfigurationPreflightContext(
|
||||||
tenant_id=context.tenant_id,
|
tenant_id=context.tenant_id,
|
||||||
operator_user_id=context.operator_user_id,
|
operator_user_id=context.operator_user_id,
|
||||||
|
operator_scopes=context.operator_scopes,
|
||||||
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
||||||
installed_modules=context.installed_modules,
|
installed_modules=context.installed_modules,
|
||||||
capabilities=context.capabilities,
|
capabilities=context.capabilities,
|
||||||
external_provider_declarations=context.external_provider_declarations,
|
external_provider_declarations=context.external_provider_declarations,
|
||||||
external_provider_states=context.external_provider_states,
|
external_provider_states=context.external_provider_states,
|
||||||
|
infrastructure_receipt=context.infrastructure_receipt,
|
||||||
|
infrastructure_receipt_error=context.infrastructure_receipt_error,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||||
|
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||||
|
CAPABILITY_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"configured",
|
||||||
|
"available_unconfigured",
|
||||||
|
"externally_supplied",
|
||||||
|
"unavailable",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
class InfrastructureCapabilityReceiptError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@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]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"state": self.state,
|
||||||
|
"source": self.source,
|
||||||
|
"detail": self.detail,
|
||||||
|
"endpoint": dict(self.endpoint),
|
||||||
|
"secret_refs": list(self.secret_refs),
|
||||||
|
"dependent_modules": list(self.dependent_modules),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InfrastructurePostInstallTask:
|
||||||
|
id: str
|
||||||
|
resume_key: str
|
||||||
|
capability_id: str
|
||||||
|
state: str
|
||||||
|
owner_module: str
|
||||||
|
summary: str
|
||||||
|
required_inputs: tuple[str, ...]
|
||||||
|
secret_boundary: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"resume_key": self.resume_key,
|
||||||
|
"capability_id": self.capability_id,
|
||||||
|
"state": self.state,
|
||||||
|
"owner_module": self.owner_module,
|
||||||
|
"summary": self.summary,
|
||||||
|
"required_inputs": list(self.required_inputs),
|
||||||
|
"secret_boundary": self.secret_boundary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InfrastructureCapabilityReceipt:
|
||||||
|
installation_id: str
|
||||||
|
profile: str
|
||||||
|
capabilities: tuple[InfrastructureCapability, ...]
|
||||||
|
post_install_tasks: tuple[InfrastructurePostInstallTask, ...]
|
||||||
|
schema_version: int = 1
|
||||||
|
|
||||||
|
def capability(self, capability_id: str) -> InfrastructureCapability | None:
|
||||||
|
return next(
|
||||||
|
(item for item in self.capabilities if item.id == capability_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tasks_for(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
capability_id: str | None = None,
|
||||||
|
owner_module: str | None = None,
|
||||||
|
) -> tuple[InfrastructurePostInstallTask, ...]:
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in self.post_install_tasks
|
||||||
|
if (capability_id is None or item.capability_id == capability_id)
|
||||||
|
and (owner_module is None or item.owner_module == owner_module)
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"installation_id": self.installation_id,
|
||||||
|
"profile": self.profile,
|
||||||
|
"capabilities": [item.to_dict() for item in self.capabilities],
|
||||||
|
"post_install_tasks": [
|
||||||
|
item.to_dict() for item in self.post_install_tasks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_infrastructure_capability_receipt(
|
||||||
|
path: Path | str | None = None,
|
||||||
|
) -> InfrastructureCapabilityReceipt | None:
|
||||||
|
configured_path = path
|
||||||
|
if configured_path is None:
|
||||||
|
raw_path = os.getenv(DEPLOYMENT_CAPABILITIES_ENV, "").strip()
|
||||||
|
if not raw_path:
|
||||||
|
return None
|
||||||
|
configured_path = raw_path
|
||||||
|
return read_infrastructure_capability_receipt(Path(configured_path))
|
||||||
|
|
||||||
|
|
||||||
|
def read_infrastructure_capability_receipt(
|
||||||
|
path: Path,
|
||||||
|
) -> InfrastructureCapabilityReceipt:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt is not a regular file."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
expected_size = path.stat().st_size
|
||||||
|
if expected_size > MAX_CAPABILITY_DOCUMENT_BYTES:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt exceeds 256 KiB."
|
||||||
|
)
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt could not be read."
|
||||||
|
) from exc
|
||||||
|
if len(raw) != expected_size:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt changed while being read."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt is not valid UTF-8 JSON."
|
||||||
|
) from exc
|
||||||
|
return infrastructure_capability_receipt_from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def infrastructure_capability_receipt_from_mapping(
|
||||||
|
payload: object,
|
||||||
|
) -> InfrastructureCapabilityReceipt:
|
||||||
|
if (
|
||||||
|
not isinstance(payload, Mapping)
|
||||||
|
or type(payload.get("schema_version")) is not int
|
||||||
|
or payload.get("schema_version") != 1
|
||||||
|
):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt has an unsupported schema."
|
||||||
|
)
|
||||||
|
raw_capabilities = payload.get("capabilities")
|
||||||
|
if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 100:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt has invalid capabilities."
|
||||||
|
)
|
||||||
|
capabilities = tuple(_capability(item) for item in raw_capabilities)
|
||||||
|
capability_ids = [item.id for item in capabilities]
|
||||||
|
if len(capability_ids) != len(set(capability_ids)):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt repeats a capability id."
|
||||||
|
)
|
||||||
|
raw_tasks = payload.get("post_install_tasks", [])
|
||||||
|
if not isinstance(raw_tasks, list) or len(raw_tasks) > 100:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability receipt has invalid post-install tasks."
|
||||||
|
)
|
||||||
|
tasks = tuple(_task(item) for item in raw_tasks)
|
||||||
|
known_capability_ids = set(capability_ids)
|
||||||
|
if any(item.capability_id not in known_capability_ids for item in tasks):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment post-install task references an unknown capability."
|
||||||
|
)
|
||||||
|
return InfrastructureCapabilityReceipt(
|
||||||
|
installation_id=_required_text(payload, "installation_id", maximum=100),
|
||||||
|
profile=_required_text(payload, "profile", maximum=100),
|
||||||
|
capabilities=capabilities,
|
||||||
|
post_install_tasks=tasks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_capability_status(
|
||||||
|
path: Path | str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
receipt = load_infrastructure_capability_receipt(path)
|
||||||
|
except InfrastructureCapabilityReceiptError as exc:
|
||||||
|
return _unavailable_status(configured=True, error=str(exc))
|
||||||
|
if receipt is None:
|
||||||
|
return _unavailable_status(configured=False, error=None)
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"available": True,
|
||||||
|
**receipt.to_dict(),
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(value: object) -> InfrastructureCapability:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability entries must be objects."
|
||||||
|
)
|
||||||
|
state = _required_text(value, "state", maximum=40)
|
||||||
|
if state not in CAPABILITY_STATES:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
f"Deployment capability state is unsupported: {state!r}."
|
||||||
|
)
|
||||||
|
normalized_endpoint = _normalized_endpoint(value.get("endpoint", {}))
|
||||||
|
secret_refs = _string_list(value.get("secret_refs"), maximum_items=30)
|
||||||
|
if any(not _ENV_REFERENCE_RE.fullmatch(item) for item in secret_refs):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability secrets must use environment references."
|
||||||
|
)
|
||||||
|
return InfrastructureCapability(
|
||||||
|
id=_required_text(value, "id", maximum=120),
|
||||||
|
label=_required_text(value, "label", maximum=200),
|
||||||
|
state=state,
|
||||||
|
source=_required_text(value, "source", maximum=120),
|
||||||
|
detail=_required_text(value, "detail", maximum=1000),
|
||||||
|
endpoint=normalized_endpoint,
|
||||||
|
secret_refs=secret_refs,
|
||||||
|
dependent_modules=_string_list(
|
||||||
|
value.get("dependent_modules"),
|
||||||
|
maximum_items=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_endpoint(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, Mapping) or len(value) > 10:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability endpoint metadata is invalid."
|
||||||
|
)
|
||||||
|
endpoint: dict[str, object] = {}
|
||||||
|
for key, raw in value.items():
|
||||||
|
if not isinstance(key, str) or not key or len(key) > 50:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability endpoint key is invalid."
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
marker in key.casefold()
|
||||||
|
for marker in ("password", "secret", "token", "credential")
|
||||||
|
):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability endpoint metadata contains a secret field."
|
||||||
|
)
|
||||||
|
if key.casefold() == "port" and (
|
||||||
|
type(raw) is not int or not 1 <= raw <= 65535
|
||||||
|
):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability endpoint port is invalid."
|
||||||
|
)
|
||||||
|
if isinstance(raw, bool) or raw is None:
|
||||||
|
endpoint[key] = raw
|
||||||
|
elif isinstance(raw, int):
|
||||||
|
endpoint[key] = raw
|
||||||
|
elif isinstance(raw, str) and len(raw) <= 500:
|
||||||
|
endpoint[key] = raw
|
||||||
|
else:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability endpoint value is invalid."
|
||||||
|
)
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _task(value: object) -> InfrastructurePostInstallTask:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment post-install task entries must be objects."
|
||||||
|
)
|
||||||
|
return InfrastructurePostInstallTask(
|
||||||
|
id=_required_text(value, "id", maximum=120),
|
||||||
|
resume_key=_required_text(value, "resume_key", maximum=240),
|
||||||
|
capability_id=_required_text(value, "capability_id", maximum=120),
|
||||||
|
state=_required_text(value, "state", maximum=40),
|
||||||
|
owner_module=_required_text(value, "owner_module", maximum=120),
|
||||||
|
summary=_required_text(value, "summary", maximum=1000),
|
||||||
|
required_inputs=_string_list(
|
||||||
|
value.get("required_inputs"),
|
||||||
|
maximum_items=30,
|
||||||
|
),
|
||||||
|
secret_boundary=_required_text(
|
||||||
|
value,
|
||||||
|
"secret_boundary",
|
||||||
|
maximum=120,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
) -> str:
|
||||||
|
raw = value.get(key)
|
||||||
|
text = str(raw).strip() if raw is not None else ""
|
||||||
|
if not text or len(text) > maximum:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
f"Deployment capability field {key!r} is invalid."
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object, *, maximum_items: int) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list) or len(value) > maximum_items:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability list field is invalid."
|
||||||
|
)
|
||||||
|
result: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, str) or not item.strip() or len(item) > 500:
|
||||||
|
raise InfrastructureCapabilityReceiptError(
|
||||||
|
"Deployment capability list item is invalid."
|
||||||
|
)
|
||||||
|
result.append(item.strip())
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"configured": configured,
|
||||||
|
"available": False,
|
||||||
|
"schema_version": None,
|
||||||
|
"installation_id": None,
|
||||||
|
"profile": None,
|
||||||
|
"capabilities": [],
|
||||||
|
"post_install_tasks": [],
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_STATES",
|
||||||
|
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||||
|
"InfrastructureCapability",
|
||||||
|
"InfrastructureCapabilityReceipt",
|
||||||
|
"InfrastructureCapabilityReceiptError",
|
||||||
|
"InfrastructurePostInstallTask",
|
||||||
|
"deployment_capability_status",
|
||||||
|
"infrastructure_capability_receipt_from_mapping",
|
||||||
|
"load_infrastructure_capability_receipt",
|
||||||
|
"read_infrastructure_capability_receipt",
|
||||||
|
]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceiptError,
|
||||||
|
deployment_capability_status,
|
||||||
|
infrastructure_capability_receipt_from_mapping,
|
||||||
|
load_infrastructure_capability_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": "govoplan-test",
|
||||||
|
"profile": "evaluation",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp",
|
||||||
|
"label": "SMTP delivery",
|
||||||
|
"state": "available_unconfigured",
|
||||||
|
"source": "installer-managed-test",
|
||||||
|
"detail": "Mail owns the profile binding.",
|
||||||
|
"endpoint": {"scheme": "smtp", "host": "test-mail", "port": 3025},
|
||||||
|
"secret_refs": [],
|
||||||
|
"dependent_modules": ["mail"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post_install_tasks": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp-profile",
|
||||||
|
"resume_key": "govoplan-test:mail.smtp-profile:v1",
|
||||||
|
"capability_id": "mail.smtp",
|
||||||
|
"state": "pending",
|
||||||
|
"owner_module": "mail",
|
||||||
|
"summary": "Create a Mail SMTP profile.",
|
||||||
|
"required_inputs": ["credential envelope reference when required"],
|
||||||
|
"secret_boundary": "credential-envelope-reference-only",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
||||||
|
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||||
|
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
||||||
|
|
||||||
|
capability = receipt.capability("mail.smtp")
|
||||||
|
self.assertIsNotNone(capability)
|
||||||
|
assert capability is not None
|
||||||
|
self.assertEqual("test-mail", capability.endpoint["host"])
|
||||||
|
self.assertEqual(
|
||||||
|
"govoplan-test:mail.smtp-profile:v1",
|
||||||
|
receipt.tasks_for(capability_id="mail.smtp", owner_module="mail")[0].resume_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(_receipt_payload(), receipt.to_dict())
|
||||||
|
|
||||||
|
def test_loader_uses_configured_path_and_status_remains_non_secret(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-capability-receipt-") as root:
|
||||||
|
path = Path(root) / "capabilities.json"
|
||||||
|
path.write_text(json.dumps(_receipt_payload()), encoding="utf-8")
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH": str(path)},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
receipt = load_infrastructure_capability_receipt()
|
||||||
|
status = deployment_capability_status()
|
||||||
|
|
||||||
|
self.assertIsNotNone(receipt)
|
||||||
|
self.assertTrue(status["available"])
|
||||||
|
self.assertNotIn("password", json.dumps(status).casefold())
|
||||||
|
|
||||||
|
def test_rejects_inline_secret_endpoint_and_non_environment_secret_reference(self) -> None:
|
||||||
|
for endpoint, secret_refs in (
|
||||||
|
({"password": "inline"}, []),
|
||||||
|
({}, ["plaintext-secret"]),
|
||||||
|
):
|
||||||
|
with self.subTest(endpoint=endpoint, secret_refs=secret_refs):
|
||||||
|
payload = _receipt_payload()
|
||||||
|
capability = dict(payload["capabilities"][0]) # type: ignore[index]
|
||||||
|
capability["endpoint"] = endpoint
|
||||||
|
capability["secret_refs"] = secret_refs
|
||||||
|
payload["capabilities"] = [capability]
|
||||||
|
|
||||||
|
with self.assertRaises(InfrastructureCapabilityReceiptError):
|
||||||
|
infrastructure_capability_receipt_from_mapping(payload)
|
||||||
|
|
||||||
|
def test_rejects_post_install_task_for_unknown_capability(self) -> None:
|
||||||
|
payload = _receipt_payload()
|
||||||
|
task = dict(payload["post_install_tasks"][0]) # type: ignore[index]
|
||||||
|
task["capability_id"] = "unknown.capability"
|
||||||
|
payload["post_install_tasks"] = [task]
|
||||||
|
|
||||||
|
with self.assertRaises(InfrastructureCapabilityReceiptError):
|
||||||
|
infrastructure_capability_receipt_from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user