mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Require approved artifact digests for apply
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/.ampg/
|
||||
/dist/
|
||||
/examples/.ampg/
|
||||
/docs/private/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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("&", "&")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user