Require approved artifact digests for apply

This commit is contained in:
jwinterm
2026-07-06 16:30:40 -04:00
parent fbfddbf2bf
commit 0426ca673c
11 changed files with 792 additions and 31 deletions
+24 -2
View File
@@ -5,6 +5,14 @@ from pathlib import Path
import shlex
from .addresses import AddressRecord, address_registry_path, effective_address_records
from .approvals import (
ACTIVATION_ARTIFACT_KIND,
GENERIC_PLATFORM,
ApprovalInput,
ApprovalRegistry,
approval_check,
load_approval_registry,
)
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .install_plan import (
InstallStateCopy,
@@ -75,6 +83,7 @@ def activation_steps(
(record.site_id, record.protocol): record
for record in effective_address_records(config)
}
approvals = load_approval_registry(config)
steps: list[ActivationStep] = []
for issue in doctor_gateway(
@@ -111,6 +120,7 @@ def activation_steps(
transport_status=transport_status,
address_record=address_by_protocol[(site.id, protocol.name)],
output_ready=output_ready,
approvals=approvals,
)
)
@@ -168,6 +178,7 @@ def _protocol_steps(
transport_status: TransportStatus,
address_record: AddressRecord,
output_ready: bool,
approvals: ApprovalRegistry,
) -> list[ActivationStep]:
output_root = site.outputs.root / protocol.name
steps = [
@@ -189,6 +200,17 @@ def _protocol_steps(
artifacts = plan_artifacts(site, protocol)
if artifacts:
for artifact in artifacts:
approval = approval_check(
ApprovalInput(
site_id=site.id,
protocol=protocol.name,
platform=GENERIC_PLATFORM,
kind=ACTIVATION_ARTIFACT_KIND,
path=artifact.path,
content=artifact.content,
),
approvals,
)
steps.append(
ActivationStep(
site_id=site.id,
@@ -196,8 +218,8 @@ def _protocol_steps(
stage="artifact",
action="review-config",
target=str(artifact.path),
status="review",
message="review generated config before installing or reloading services",
status="ready" if approval.status == "approved" else "review",
message=approval.message,
)
)
else:
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
from typing import Iterable
from .config import GatewayConfig
from .state_contract import gateway_state_root
SCHEMA = "ampg.artifact-approvals.v1"
GENERIC_PLATFORM = "generic"
ACTIVATION_ARTIFACT_KIND = "activation-artifact"
ApprovalKey = tuple[str, str, str, str, str]
ApprovalRegistry = dict[ApprovalKey, "ArtifactApproval"]
@dataclass(frozen=True)
class ApprovalInput:
site_id: str
protocol: str
platform: str
kind: str
path: Path
content: str
@dataclass(frozen=True)
class ArtifactApproval:
site_id: str
protocol: str
platform: str
kind: str
path: Path
digest: str
approved_at: str
source: str
@dataclass(frozen=True)
class ApprovalCheck:
candidate: ApprovalInput
status: str
digest: str
message: str
@dataclass(frozen=True)
class ApprovalWriteResult:
candidate: ApprovalInput
status: str
digest: str
message: str
def approval_registry_path(config: GatewayConfig) -> Path:
return gateway_state_root(config) / "approvals.json"
def artifact_digest(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def load_approval_registry(config: GatewayConfig) -> ApprovalRegistry:
path = approval_registry_path(config)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"invalid approval registry {path}: {exc}") from exc
if data.get("schema") != SCHEMA:
raise ValueError(f"invalid approval registry schema in {path}")
approvals: ApprovalRegistry = {}
for raw_approval in data.get("approvals", []):
if not isinstance(raw_approval, dict):
continue
approval = ArtifactApproval(
site_id=str(raw_approval.get("site", "")),
protocol=str(raw_approval.get("protocol", "")),
platform=str(raw_approval.get("platform", "")),
kind=str(raw_approval.get("kind", "")),
path=_normalized_path(Path(str(raw_approval.get("path", "")))),
digest=str(raw_approval.get("sha256", "")),
approved_at=str(raw_approval.get("approved_at", "")),
source=str(raw_approval.get("source", "manual")),
)
if all(
(
approval.site_id,
approval.protocol,
approval.platform,
approval.kind,
approval.digest,
)
):
approvals[_approval_key_for_values(
approval.site_id,
approval.protocol,
approval.platform,
approval.kind,
approval.path,
)] = approval
return approvals
def write_approval_registry(
config: GatewayConfig,
approvals: ApprovalRegistry,
) -> Path:
path = approval_registry_path(config)
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"schema": SCHEMA,
"approvals": [
{
"site": approval.site_id,
"protocol": approval.protocol,
"platform": approval.platform,
"kind": approval.kind,
"path": str(approval.path),
"sha256": approval.digest,
"approved_at": approval.approved_at,
"source": approval.source,
}
for approval in sorted(
approvals.values(),
key=lambda item: (
item.site_id,
item.protocol,
item.platform,
item.kind,
str(item.path),
),
)
],
}
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
def approval_check(
candidate: ApprovalInput,
approvals: ApprovalRegistry,
) -> ApprovalCheck:
expected_digest = artifact_digest(candidate.content)
if not candidate.path.exists():
return ApprovalCheck(
candidate=candidate,
status="missing",
digest=expected_digest,
message="artifact is missing; rerun with --write-artifacts first",
)
actual_digest = _path_digest(candidate.path)
if actual_digest != expected_digest:
return ApprovalCheck(
candidate=candidate,
status="stale",
digest=expected_digest,
message="artifact differs from current generator output; rerun with --write-artifacts",
)
approval = approvals.get(approval_key(candidate))
if approval is None:
return ApprovalCheck(
candidate=candidate,
status="review",
digest=expected_digest,
message="artifact content is not approved; run approvals approve after review",
)
if approval.digest != expected_digest:
return ApprovalCheck(
candidate=candidate,
status="review",
digest=expected_digest,
message="approval digest does not match current artifact content",
)
return ApprovalCheck(
candidate=candidate,
status="approved",
digest=expected_digest,
message="artifact content digest is approved",
)
def approve_artifacts(
config: GatewayConfig,
candidates: Iterable[ApprovalInput],
*,
source: str = "manual",
) -> list[ApprovalWriteResult]:
approvals = load_approval_registry(config)
results: list[ApprovalWriteResult] = []
changed = False
for candidate in candidates:
check = approval_check(candidate, approvals)
if check.status in {"missing", "stale"}:
results.append(
ApprovalWriteResult(
candidate=candidate,
status=check.status,
digest=check.digest,
message=check.message,
)
)
continue
status = "current" if check.status == "approved" else "written"
if status == "written":
changed = True
approvals[approval_key(candidate)] = ArtifactApproval(
site_id=candidate.site_id,
protocol=candidate.protocol,
platform=candidate.platform,
kind=candidate.kind,
path=_normalized_path(candidate.path),
digest=check.digest,
approved_at=_utc_now(),
source=source,
)
results.append(
ApprovalWriteResult(
candidate=candidate,
status=status,
digest=check.digest,
message="approval recorded" if status == "written" else check.message,
)
)
if changed:
write_approval_registry(config, approvals)
return results
def approval_key(candidate: ApprovalInput) -> ApprovalKey:
return _approval_key_for_values(
candidate.site_id,
candidate.protocol,
candidate.platform,
candidate.kind,
candidate.path,
)
def _approval_key_for_values(
site_id: str,
protocol: str,
platform: str,
kind: str,
path: Path,
) -> ApprovalKey:
return (site_id, protocol, platform, kind, str(_normalized_path(path)))
def _path_digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _normalized_path(path: Path) -> Path:
return path.expanduser().resolve()
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
+190 -1
View File
@@ -21,6 +21,17 @@ from .addresses import (
effective_address_records,
set_address,
)
from .approvals import (
ACTIVATION_ARTIFACT_KIND,
GENERIC_PLATFORM,
ApprovalCheck,
ApprovalInput,
ApprovalWriteResult,
approval_check,
approval_registry_path,
approve_artifacts,
load_approval_registry,
)
from .audit import audit_gateway
from .build import build_gateway
from .config import load_config
@@ -30,6 +41,7 @@ from .install_plan import (
InstallStateCopy,
InstallStep,
InstallSupervisorAction,
install_artifacts,
blocked_install_steps,
install_state_copies,
install_supervisor_actions,
@@ -37,7 +49,7 @@ from .install_plan import (
write_install_artifacts,
)
from .manifest import write_fixture_manifests
from .plan import plan_gateway, write_plan_artifacts
from .plan import plan_artifacts, plan_gateway, write_plan_artifacts
from .platforms import PLATFORM_NAMES, platform_by_name
from .preview import PreviewServers, preview_endpoints, write_preview_fixture_manifests
from .route_manifest import (
@@ -107,6 +119,51 @@ def main(argv: list[str] | None = None) -> int:
choices=PLATFORM_NAMES,
help="Override platform detection for dry-run checks.",
)
approvals_parser = subcommands.add_parser(
"approvals",
help="Inspect or approve generated artifact digests.",
)
approvals_subcommands = approvals_parser.add_subparsers(
dest="approvals_command",
required=True,
)
approvals_list = approvals_subcommands.add_parser(
"list",
help="List generated artifacts and approval status.",
)
_add_target_selection(approvals_list)
approvals_list.add_argument(
"--platform",
choices=PLATFORM_NAMES,
help="Override platform detection for approval candidates.",
)
approvals_list.add_argument(
"--kind",
action="append",
default=[],
help="Limit to an approval kind. May be repeated or comma-separated.",
)
approvals_approve = approvals_subcommands.add_parser(
"approve",
help="Record approvals for reviewed artifact digests.",
)
_add_target_selection(approvals_approve)
approvals_approve.add_argument(
"--platform",
choices=PLATFORM_NAMES,
help="Override platform detection for approval candidates.",
)
approvals_approve.add_argument(
"--kind",
action="append",
default=[],
help="Limit to an approval kind. May be repeated or comma-separated.",
)
approvals_approve.add_argument(
"--all",
action="store_true",
help="Approve all selected generated artifact kinds.",
)
install_plan_parser = subcommands.add_parser(
"install-plan",
help="Print managed daemon package, state, and supervisor steps.",
@@ -272,6 +329,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_doctor(config, args)
if args.command == "apply":
return _cmd_apply(config, args)
if args.command == "approvals":
return _cmd_approvals(config, args)
if args.command == "install-plan":
return _cmd_install_plan(config, args)
if args.command == "health-plan":
@@ -345,6 +404,47 @@ def _cmd_apply(config, args) -> int:
return 1 if preflight.status == "blocked" else 0
def _cmd_approvals(config, args) -> int:
platform_provider = _platform_override(config, args)
kinds = _kind_filter(args)
candidates = _approval_candidates(config, platform_provider=platform_provider)
if kinds:
candidates = [candidate for candidate in candidates if candidate.kind in kinds]
if args.approvals_command == "list":
approvals = load_approval_registry(config)
checks = [approval_check(candidate, approvals) for candidate in candidates]
for check in checks:
_print_approval_check(check)
_print_approval_summary(
mode="list",
statuses=[check.status for check in checks],
count=len(checks),
registry=approval_registry_path(config),
)
return 0
if args.approvals_command == "approve":
if not args.all and not kinds:
print(
'AMPG_APPROVAL status=error message="pass --all or --kind before approving"',
file=sys.stderr,
)
return 1
results = approve_artifacts(config, candidates)
for result in results:
_print_approval_write_result(result)
_print_approval_summary(
mode="approve",
statuses=[result.status for result in results],
count=len(results),
registry=approval_registry_path(config),
)
return 1 if any(result.status in {"missing", "stale"} for result in results) else 0
return 1
def _cmd_install_plan(config, args) -> int:
platform_provider = _platform_override(config, args)
if _write_artifacts_enabled(config, args):
@@ -427,6 +527,44 @@ def _write_artifacts_enabled(config, args) -> bool:
return bool(profile and profile.write_artifacts)
def _approval_candidates(config, *, platform_provider) -> list[ApprovalInput]:
candidates: list[ApprovalInput] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
for artifact in plan_artifacts(site, protocol):
candidates.append(
ApprovalInput(
site_id=site.id,
protocol=protocol.name,
platform=GENERIC_PLATFORM,
kind=ACTIVATION_ARTIFACT_KIND,
path=artifact.path,
content=artifact.content,
)
)
candidates.extend(
ApprovalInput(
site_id=artifact.site_id,
protocol=artifact.protocol,
platform=artifact.platform,
kind=artifact.kind,
path=artifact.path,
content=artifact.content,
)
for artifact in install_artifacts(config, platform_provider=platform_provider)
)
return candidates
def _kind_filter(args) -> set[str]:
kinds: set[str] = set()
for raw_kind in getattr(args, "kind", ()):
kinds.update(kind.strip() for kind in raw_kind.split(",") if kind.strip())
return kinds
def _cmd_status(config, args) -> int:
statuses = gateway_status(config, platform_provider=_platform_override(config, args))
for status in statuses:
@@ -607,6 +745,57 @@ def _print_apply_preflight_item(item: ApplyPreflightItem) -> None:
)
def _print_approval_check(check: ApprovalCheck) -> None:
candidate = check.candidate
print(
"AMPG_APPROVAL "
f"site={candidate.site_id} "
f"protocol={candidate.protocol} "
f"platform={candidate.platform} "
f"kind={candidate.kind} "
f"path=\"{_quote(str(candidate.path))}\" "
f"status={check.status} "
f"sha256={check.digest} "
f"message=\"{_quote(check.message)}\""
)
def _print_approval_write_result(result: ApprovalWriteResult) -> None:
candidate = result.candidate
print(
"AMPG_APPROVAL_WRITE "
f"site={candidate.site_id} "
f"protocol={candidate.protocol} "
f"platform={candidate.platform} "
f"kind={candidate.kind} "
f"path=\"{_quote(str(candidate.path))}\" "
f"status={result.status} "
f"sha256={result.digest} "
f"message=\"{_quote(result.message)}\""
)
def _print_approval_summary(
*,
mode: str,
statuses: list[str],
count: int,
registry: Path,
) -> None:
print(
"AMPG_APPROVAL_SUMMARY "
f"mode={mode} "
f"candidates={count} "
f"approved={statuses.count('approved')} "
f"review={statuses.count('review')} "
f"written={statuses.count('written')} "
f"current={statuses.count('current')} "
f"missing={statuses.count('missing')} "
f"stale={statuses.count('stale')} "
f"registry=\"{_quote(str(registry))}\""
)
def _print_activation_step(step: ActivationStep) -> None:
print(
"AMPG_APPLY_STEP "
+51 -12
View File
@@ -4,6 +4,12 @@ from dataclasses import dataclass
from pathlib import Path
import shlex
from .approvals import (
ApprovalInput,
ApprovalRegistry,
approval_check,
load_approval_registry,
)
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .platforms import PlatformProvider, detect_platform
from .state_contract import daemon_config_path, protocol_state_dir
@@ -146,13 +152,23 @@ def install_state_copies(
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
approvals = load_approval_registry(config)
copies: list[InstallStateCopy] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
copies.extend(_protocol_state_copies(config, site, protocol, provider, status))
copies.extend(
_protocol_state_copies(
config,
site,
protocol,
provider,
status,
approvals=approvals,
)
)
return copies
@@ -171,13 +187,23 @@ def install_supervisor_actions(
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
approvals = load_approval_registry(config)
actions: list[InstallSupervisorAction] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
actions.extend(_protocol_supervisor_actions(config, site, protocol, provider, status))
actions.extend(
_protocol_supervisor_actions(
config,
site,
protocol,
provider,
status,
approvals=approvals,
)
)
return actions
@@ -362,13 +388,14 @@ def _protocol_state_copies(
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
approvals: ApprovalRegistry,
) -> list[InstallStateCopy]:
copies: list[InstallStateCopy] = []
for artifact in _protocol_install_artifacts(config, site, protocol, provider, status):
target = _state_copy_target(config, site, protocol, artifact)
if target is None:
continue
source_exists = artifact.path.exists()
approval = approval_check(_approval_input(artifact), approvals)
copies.append(
InstallStateCopy(
site_id=site.id,
@@ -377,12 +404,12 @@ def _protocol_state_copies(
kind=artifact.kind,
source=artifact.path,
target=target,
status="planned" if source_exists else "review",
status="planned" if approval.status == "approved" else "review",
command=_copy_command(provider, artifact.path, target),
message=(
"copy reviewed artifact into managed state before daemon start"
if source_exists
else "review artifact is missing; rerun with --write-artifacts first"
"copy approved artifact into managed state before daemon start"
if approval.status == "approved"
else approval.message
),
)
)
@@ -395,6 +422,7 @@ def _protocol_supervisor_actions(
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
approvals: ApprovalRegistry,
) -> list[InstallSupervisorAction]:
actions: list[InstallSupervisorAction] = []
for artifact in _protocol_install_artifacts(config, site, protocol, provider, status):
@@ -402,7 +430,7 @@ def _protocol_supervisor_actions(
if service_suffix is None:
continue
service = _artifact_service_name(site, protocol, service_suffix)
source_exists = artifact.path.exists()
approval = approval_check(_approval_input(artifact), approvals)
actions.append(
InstallSupervisorAction(
site_id=site.id,
@@ -411,12 +439,12 @@ def _protocol_supervisor_actions(
kind=artifact.kind,
service=service,
source=artifact.path,
status="planned" if source_exists else "review",
status="planned" if approval.status == "approved" else "review",
command=_supervisor_start_command(provider, service),
message=(
"register and start reviewed supervisor after state config is copied"
if source_exists
else "supervisor artifact is missing; rerun with --write-artifacts first"
"register and start approved supervisor after state config is copied"
if approval.status == "approved"
else approval.message
),
)
)
@@ -812,6 +840,17 @@ def _artifact(
)
def _approval_input(artifact: InstallArtifact) -> ApprovalInput:
return ApprovalInput(
site_id=artifact.site_id,
protocol=artifact.protocol,
platform=artifact.platform,
kind=artifact.kind,
path=artifact.path,
content=artifact.content,
)
def _xml_escape(value: str) -> str:
return (
value.replace("&", "&")