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
+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 "