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
+1
View File
@@ -1,5 +1,6 @@
/.ampg/
/dist/
/examples/.ampg/
/docs/private/
__pycache__/
*.pyc
+11 -4
View File
@@ -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.
+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("&", "&")
+7 -4
View File
@@ -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
+10 -3
View File
@@ -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
+7 -3
View File
@@ -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.
+174
View File
@@ -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("<h1>Hello</h1>", encoding="utf-8")
config_path = root / "gateway.toml"
config_path.write_text(
"""
[[site]]
id = "example"
domain = "example.test"
[site.source]
kind = "static-html"
path = "./site"
[site.outputs]
root = "./out"
plan_root = "./plan"
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
i2p_url = "ready.b32.i2p"
""",
encoding="utf-8",
)
return config_path
def _run_cli(args: list[str]) -> tuple[int, str, str]:
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
status = main(args)
return status, stdout.getvalue(), stderr.getvalue()
class ApprovalsTest(unittest.TestCase):
def test_approval_cli_can_move_apply_preflight_to_ready(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _write_config(root)
output = root / "out/i2p"
output.mkdir(parents=True)
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
status, output_text, _ = _run_cli(
[
"--config",
str(config_path),
"apply",
"--dry-run",
"--write-artifacts",
"--platform",
"android-termux",
]
)
self.assertEqual(0, status)
self.assertIn("AMPG_APPLY_PREFLIGHT status=review", output_text)
status, output_text, _ = _run_cli(
[
"--config",
str(config_path),
"approvals",
"approve",
"--all",
"--platform",
"android-termux",
]
)
self.assertEqual(0, status)
self.assertIn("AMPG_APPROVAL_SUMMARY mode=approve candidates=6", output_text)
self.assertIn("written=6", output_text)
self.assertTrue((root / ".ampg/state/approvals.json").exists())
status, output_text, _ = _run_cli(
[
"--config",
str(config_path),
"apply",
"--dry-run",
"--write-artifacts",
"--platform",
"android-termux",
]
)
self.assertEqual(0, status)
self.assertIn("AMPG_APPLY_PREFLIGHT status=ready", output_text)
self.assertNotIn("AMPG_APPLY_PREFLIGHT_ITEM", output_text)
def test_approval_cli_reports_stale_artifact_after_edit(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _write_config(root)
output = root / "out/i2p"
output.mkdir(parents=True)
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
_run_cli(
[
"--config",
str(config_path),
"apply",
"--dry-run",
"--write-artifacts",
"--platform",
"android-termux",
]
)
_run_cli(
[
"--config",
str(config_path),
"approvals",
"approve",
"--kind",
"daemon-config",
"--platform",
"android-termux",
]
)
artifact = root / "plan/example/i2p/managed/android-termux/i2pd-tunnels.conf"
artifact.write_text(artifact.read_text(encoding="utf-8") + "# edited\n", encoding="utf-8")
status, output_text, _ = _run_cli(
[
"--config",
str(config_path),
"approvals",
"list",
"--kind",
"daemon-config",
"--platform",
"android-termux",
]
)
self.assertEqual(0, status)
self.assertIn("status=stale", output_text)
self.assertIn("artifact differs from current generator output", output_text)
def test_approval_cli_requires_explicit_scope(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = _write_config(Path(tmp))
status, _, error_text = _run_cli(
[
"--config",
str(config_path),
"approvals",
"approve",
"--platform",
"android-termux",
]
)
self.assertEqual(1, status)
self.assertIn("pass --all or --kind", error_text)
if __name__ == "__main__":
unittest.main()
+53 -2
View File
@@ -4,6 +4,7 @@ import tempfile
import unittest
from pathlib import Path
from ampg.approvals import ApprovalInput, approve_artifacts
from ampg.cli import main
from ampg.config import load_config
from ampg.install_plan import (
@@ -294,7 +295,8 @@ daemon_policy = "auto"
by_kind = {copy.kind: copy for copy in copies}
self.assertEqual({"daemon-config", "loopback-config"}, set(by_kind))
self.assertEqual("planned", by_kind["daemon-config"].status)
self.assertEqual("review", by_kind["daemon-config"].status)
self.assertIn("not approved", by_kind["daemon-config"].message)
self.assertEqual(
root / ".ampg/state/example/i2p/i2pd-tunnels.conf",
by_kind["daemon-config"].target,
@@ -334,7 +336,8 @@ daemon_policy = "auto"
by_kind = {action.kind: action for action in actions}
self.assertEqual({"daemon-supervisor", "loopback-supervisor"}, set(by_kind))
self.assertEqual("planned", by_kind["daemon-supervisor"].status)
self.assertEqual("review", by_kind["daemon-supervisor"].status)
self.assertIn("not approved", by_kind["daemon-supervisor"].message)
self.assertEqual("ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].service)
self.assertEqual("sv-enable ampg-example-i2p-i2pd", by_kind["daemon-supervisor"].command)
self.assertEqual(
@@ -346,6 +349,54 @@ daemon_policy = "auto"
by_kind["loopback-supervisor"].command,
)
def test_approved_managed_artifacts_enable_copy_and_supervisor_plans(self):
with tempfile.TemporaryDirectory() as tmp:
config = load_config(
_write_config(
Path(tmp).resolve(),
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
""",
)
)
artifacts = write_install_artifacts(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
approve_artifacts(
config,
[
ApprovalInput(
site_id=artifact.site_id,
protocol=artifact.protocol,
platform=artifact.platform,
kind=artifact.kind,
path=artifact.path,
content=artifact.content,
)
for artifact in artifacts
],
)
copies = install_state_copies(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
actions = install_supervisor_actions(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
self.assertTrue(all(copy.status == "planned" for copy in copies))
self.assertTrue(all(action.status == "planned" for action in actions))
def test_install_plan_cli_can_write_managed_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)