diff --git a/.gitignore b/.gitignore index 2358e92..1403c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.ampg/ /dist/ +/examples/.ampg/ /docs/private/ __pycache__/ *.pyc diff --git a/README.md b/README.md index e2547cf..473f17a 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protocol i2p python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --write-artifacts +python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml approvals approve --profile mobile-i2p --all python3 -m ampg --config examples/wownero.gateway.toml build python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p @@ -150,12 +152,16 @@ Captured transport addresses are written under `gateway.state_dir` by `addresses capture` or `addresses set`. Fixture manifests and health plans use explicit config first, then captured addresses, then deterministic placeholders. +`approvals list` reports whether generated artifact digests are missing, stale, +unapproved, or approved. `approvals approve` records reviewed digests under +`gateway.state_dir`; edited or regenerated artifacts automatically fall back to review. + `apply --dry-run` includes an address stage and an `AMPG_APPLY_PREFLIGHT` gate. Placeholder addresses remain review items until the generated transport identity is -captured or configured. With `--write-artifacts`, apply also prints reviewed config -artifacts that would be copied into managed state and the supervisor services that would -be registered or started. The preflight gate reports `blocked`, `review`, or `ready` -across activation steps, managed-state copies, and supervisor actions. +captured or configured. With `--write-artifacts`, apply also prints config artifacts that +would be copied into managed state and the supervisor services that would be registered +or started. The preflight gate reports `blocked`, `review`, or `ready` across activation +steps, managed-state copies, and supervisor actions. Use `--protocol` to scope operational commands to one or more enabled protocols. This lets a full site config build or activate only Tor, only I2P, or a selected subset without @@ -175,5 +181,6 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Run `ampg build`; inspect generated variants. - [ ] Run AMPB fixture checks against generated manifests. - [ ] Run `ampg health-plan`; review post-start transport checks. +- [ ] Run `ampg approvals approve --all` after reviewing generated artifacts. - [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate. - [ ] Run live apply after the preflight gate is ready. diff --git a/ampg/activation.py b/ampg/activation.py index 313ac81..5c0bb46 100644 --- a/ampg/activation.py +++ b/ampg/activation.py @@ -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: diff --git a/ampg/approvals.py b/ampg/approvals.py new file mode 100644 index 0000000..9846e7f --- /dev/null +++ b/ampg/approvals.py @@ -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") diff --git a/ampg/cli.py b/ampg/cli.py index 170656e..9633193 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -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 " diff --git a/ampg/install_plan.py b/ampg/install_plan.py index 54e24c6..e044448 100644 --- a/ampg/install_plan.py +++ b/ampg/install_plan.py @@ -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("&", "&") diff --git a/docs/architecture.md b/docs/architecture.md index 57d8310..f4f6521 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,10 +112,11 @@ render-only environment while making daemon ownership decisions explicit. `ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered activation sequence. Each enabled protocol gets output, artifact, daemon, and health steps, plus an address step when AMPG must capture or confirm a generated transport -identity. With `--write-artifacts`, it also prints planned copies from reviewed artifacts -into managed state and planned supervisor start/reload actions. The command emits an +identity. With `--write-artifacts`, it also prints copies from approved artifacts into +managed state and planned supervisor start/reload actions. The command emits an `AMPG_APPLY_PREFLIGHT` verdict across activation, state-copy, and supervisor rows. -Blocked preflight items stop the command before any live apply path can touch services. +Blocked or unapproved preflight items stop mutation before any live apply path can touch +services. The dry-run command is intentionally stricter than `doctor`: missing generated output is blocked during activation, because AMPG must not point a transport at an unbuilt output @@ -130,7 +131,9 @@ future live apply support. With `--write-artifacts`, install-plan writes managed config and supervisor files under the configured `plan_root`; those files are review artifacts, not installed service -state. Generated managed-daemon configs point runtime state at `gateway.state_dir`. +state. `ampg approvals approve` records reviewed artifact digests under +`gateway.state_dir`. Generated managed-daemon configs point runtime state at +`gateway.state_dir`. Tor and I2P HTTP publishing include the transport daemon and the loopback web-serving layer in the install plan, because the hidden service or tunnel must have a local HTTP diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 0db615a..8cc5a9b 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -49,6 +49,8 @@ python3 -m ampg --config gateway.toml apply --dry-run python3 -m ampg --config gateway.toml apply --dry-run --profile mobile-i2p python3 -m ampg --config gateway.toml apply --dry-run --protocol i2p python3 -m ampg --config gateway.toml apply --dry-run --write-artifacts +python3 -m ampg --config gateway.toml approvals list --profile mobile-i2p +python3 -m ampg --config gateway.toml approvals approve --profile mobile-i2p --all python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -82,14 +84,19 @@ address registry under `state_dir`. target real transport URLs; preview checks target local loopback preview endpoints. Missing output blocks the plan, while placeholder transport addresses are review items. +`approvals list` prints generated artifact candidates and their digest status. `approve` +requires `--all` or a `--kind` filter, then records approvals only for artifacts that +exist and match the current generator output. Missing, edited, or stale artifacts remain +review items. + `apply --dry-run` prints the activation sequence for each enabled protocol: generated output readiness, config artifacts to review, daemon action, address capture or reuse, and post-apply health checks. It also prints `AMPG_APPLY_PREFLIGHT` plus blocked or review preflight items across activation, managed-state copies, and supervisor actions. It exits nonzero when the preflight status is blocked. `--write-artifacts` writes -reviewable config snippets to the configured plan root, then prints the planned copies -from those reviewed artifacts into `gateway.state_dir` and the supervisor services that -would be registered or started. It still does not install or reload services. +reviewable config snippets to the configured plan root, then prints the copies from +approved artifacts into `gateway.state_dir` and the supervisor services that would be +registered or started. It still does not install or reload services. `--protocol` scopes operational commands to selected enabled protocols. A clearnet `adopt` failure will block a full activation run, but it will not block diff --git a/docs/state-contract.md b/docs/state-contract.md index 98fbb9d..092cd65 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -4,7 +4,8 @@ AMPG keeps managed daemon state under `gateway.state_dir`. The state contract describes which files AMPG owns, which files daemons or adapters write, which paths may contain -private keys, and which public address files `addresses capture` reads. +private keys, which artifact approvals AMPG records, and which public address files +`addresses capture` reads. ## Command @@ -22,6 +23,9 @@ Each `AMPG_STATE` row includes: - `sensitive`: whether the path may contain private identity material. - `path`: resolved path under `gateway.state_dir` unless an adapter documents otherwise. +AMPG also stores `approvals.json` under `gateway.state_dir`. It records reviewed +generated-artifact digests and contains no private daemon identity keys. + ## Address Capture `addresses capture` uses the same contract. For each enabled protocol it checks explicit @@ -39,8 +43,8 @@ Current managed defaults: AMPG must never delete sensitive contract paths without an explicit destructive command. `apply --dry-run --write-artifacts` prints `AMPG_APPLY_STATE_COPY` rows that show which -reviewed artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows +approved artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows for the services that would be registered or started. The accompanying `AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when -state or supervisor inputs still need review. Live apply should perform reviewed copies, +state or supervisor inputs still need review. Live apply should perform approved copies, start daemons, capture public addresses, and then run published health checks. diff --git a/tests/test_approvals.py b/tests/test_approvals.py new file mode 100644 index 0000000..de1bb09 --- /dev/null +++ b/tests/test_approvals.py @@ -0,0 +1,174 @@ +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ampg.cli import main + + +def _write_config(root: Path) -> Path: + source = root / "site" + source.mkdir() + (source / "index.html").write_text("