diff --git a/README.md b/README.md index 18c80b8..490983e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Adaptive Multi-Protocol Gateway -> Status: draft | Updated 2026-07-06 | Applies to: AMPG contributors and operators +> Status: draft | Updated 2026-07-07 | Applies to: AMPG contributors and operators Adaptive Multi-Protocol Gateway, or AMPG, turns one canonical site into transport-appropriate published versions for clearnet web, Tor onion services, I2P, Gemini, IPFS, and @@ -63,6 +63,7 @@ python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --pro python3 -m ampg --config gateway.toml deploy apply --stage supervisor --dry-run --profile vps-full python3 -m ampg --config gateway.toml deploy apply --stage start --dry-run --profile vps-full python3 -m ampg --config gateway.toml deploy apply --stage addresses --dry-run --profile vps-full +python3 -m ampg --config gateway.toml deploy apply --stage health --dry-run --profile vps-full ``` `init site` writes a readable `gateway.toml` with selected transports enabled and common @@ -74,6 +75,8 @@ managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` i approved supervisor files after state exists. `deploy apply --stage start` starts AMPG-owned services after state and supervisor files are present. `deploy apply --stage addresses` captures daemon-written public addresses into the address registry. +`deploy apply --stage health` verifies published fixture URLs through the selected +transport. ## Interaction tiers @@ -145,6 +148,8 @@ python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage star python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage start --profile mobile-i2p --yes python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage addresses --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage addresses --profile mobile-i2p --yes +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage health --dry-run --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage health --profile mobile-i2p --yes 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 @@ -213,6 +218,10 @@ checks. be recorded. The live form requires `--yes`; it writes captured addresses to `gateway.state_dir` and leaves health verification for the next stage. +`deploy apply --stage health --dry-run` shows published fixture checks after addresses +are configured or captured. The live form requires `--yes`; it runs the transport check +commands and reports pass/fail without changing config or state. + `dns plan --free-domain-hints` prints optional community subdomain services that may help new users get a clearnet name without buying a domain. These are third-party registries; operators still need to review current terms, availability, content rules, and DNS @@ -242,4 +251,5 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] Run `ampg deploy apply --stage supervisor --dry-run`; confirm approved service files. - [ ] Run `ampg deploy apply --stage start --dry-run`; confirm service-manager commands. - [ ] Run `ampg deploy apply --stage addresses --dry-run`; confirm captured transport addresses. +- [ ] Run `ampg deploy apply --stage health --dry-run`; confirm published health checks. - [ ] Run live apply after the preflight gate is ready. diff --git a/ampg/apply.py b/ampg/apply.py index b81e5c6..b92950d 100644 --- a/ampg/apply.py +++ b/ampg/apply.py @@ -14,6 +14,7 @@ from .addresses import ( write_captured_address_results, ) from .config import GatewayConfig +from .health import HealthCheck, health_plan from .install_plan import ( InstallStateCopy, InstallSupervisorAction, @@ -87,6 +88,21 @@ class AddressApplyResult: message: str +@dataclass(frozen=True) +class HealthApplyResult: + site_id: str + protocol: str + route: str + transport: str + profile: str + url: str + status: str + mode: str + command: tuple[str, ...] + return_code: int | None + message: str + + CommandRunner = Callable[[tuple[str, ...]], CommandRunResult] @@ -281,6 +297,59 @@ def apply_addresses( return [_address_result(result, dry_run=dry_run) for result in captures] +def apply_health( + config: GatewayConfig, + *, + dry_run: bool, + command_runner: CommandRunner | None = None, +) -> list[HealthApplyResult]: + checks = health_plan(config) + runner = command_runner or _run_command + results: list[HealthApplyResult] = [] + for check in checks: + command = _health_command(check) + validation = _validate_health_check(check, command=command, dry_run=dry_run) + if validation.status == "blocked": + results.append(validation) + continue + if dry_run: + results.append( + _health_result( + check, + command=command, + status="planned", + mode="dry-run", + return_code=None, + message="would run published health check", + ) + ) + continue + run = runner(command) + if run.return_code != 0: + results.append( + _health_result( + check, + command=command, + status="blocked", + mode="live", + return_code=run.return_code, + message=_command_failure_message(run), + ) + ) + continue + results.append( + _health_result( + check, + command=command, + status="passed", + mode="live", + return_code=run.return_code, + message="published health check passed", + ) + ) + return results + + def _validate_state_copy( config: GatewayConfig, copy: InstallStateCopy, @@ -480,6 +549,41 @@ def _validate_start_action( ) +def _validate_health_check( + check: HealthCheck, + *, + command: tuple[str, ...], + dry_run: bool, +) -> HealthApplyResult: + mode = "dry-run" if dry_run else "live" + if check.status != "planned": + return _health_result( + check, + command=command, + status="blocked", + mode=mode, + return_code=None, + message=check.message, + ) + if not command: + return _health_result( + check, + command=command, + status="blocked", + mode=mode, + return_code=None, + message="health check command is unavailable", + ) + return _health_result( + check, + command=command, + status="ready", + mode=mode, + return_code=None, + message="health check passed safety checks", + ) + + def _address_result( result: AddressCaptureResult, *, @@ -510,6 +614,30 @@ def _address_result( ) +def _health_result( + check: HealthCheck, + *, + command: tuple[str, ...], + status: str, + mode: str, + return_code: int | None, + message: str, +) -> HealthApplyResult: + return HealthApplyResult( + site_id=check.site_id, + protocol=check.protocol, + route=check.route, + transport=check.transport, + profile=check.profile, + url=check.url, + status=status, + mode=mode, + command=command, + return_code=return_code, + message=message, + ) + + def _result( copy: InstallStateCopy, *, @@ -603,6 +731,13 @@ def start_command( return () +def _health_command(check: HealthCheck) -> tuple[str, ...]: + try: + return tuple(shlex.split(check.command)) + except ValueError: + return () + + def _termux_prefix(state_root: Path) -> Path: if len(state_root.parts) >= 3 and state_root.parts[-3:] == ("var", "lib", "ampg"): return state_root.parents[2] diff --git a/ampg/cli.py b/ampg/cli.py index a1407d7..1c7e3e3 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -23,10 +23,12 @@ from .addresses import ( ) from .apply import ( AddressApplyResult, + HealthApplyResult, StateApplyResult, StartApplyResult, SupervisorApplyResult, apply_addresses, + apply_health, apply_state, apply_start, apply_supervisor, @@ -162,7 +164,7 @@ def main(argv: list[str] | None = None) -> int: _add_target_selection(deploy_apply_parser) deploy_apply_parser.add_argument( "--stage", - choices=("state", "supervisor", "start", "addresses"), + choices=("state", "supervisor", "start", "addresses", "health"), required=True, help="Deployment stage to apply.", ) @@ -664,6 +666,23 @@ def _cmd_deploy(config, args) -> int: f"registry=\"{_quote(str(address_registry_path(config)))}\"" ) return 1 if blocked else 0 + if args.stage == "health": + results = apply_health(config, dry_run=args.dry_run) + for result in results: + _print_health_apply_result(result) + blocked = [result for result in results if result.status == "blocked"] + passed = [result for result in results if result.status == "passed"] + planned = [result for result in results if result.status == "planned"] + print( + "AMPG_DEPLOY_APPLY_SUMMARY " + f"stage=health " + f"mode={'dry-run' if args.dry_run else 'live'} " + f"results={len(results)} " + f"planned={len(planned)} " + f"passed={len(passed)} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 return 1 @@ -1202,6 +1221,24 @@ def _print_address_apply_result(result: AddressApplyResult) -> None: ) +def _print_health_apply_result(result: HealthApplyResult) -> None: + return_code = "-" if result.return_code is None else str(result.return_code) + print( + "AMPG_DEPLOY_HEALTH " + f"site={result.site_id} " + f"protocol={result.protocol} " + f"route=\"{_quote(result.route)}\" " + f"transport={result.transport} " + f"profile={result.profile} " + f"mode={result.mode} " + f"url=\"{_quote(result.url)}\" " + f"status={result.status} " + f"return_code={return_code} " + f"command=\"{_quote(format_command(result.command))}\" " + f"message=\"{_quote(result.message)}\"" + ) + + def _print_dns_record(record: DNSRecordPlan) -> None: print( "AMPG_DNS_RECORD " diff --git a/ampg/deploy.py b/ampg/deploy.py index 698c694..e434c95 100644 --- a/ampg/deploy.py +++ b/ampg/deploy.py @@ -16,6 +16,7 @@ from .approvals import ( ) from .config import GatewayConfig from .dns import dns_plan +from .health import health_plan from .install_plan import ( install_artifacts, install_plan, @@ -95,6 +96,7 @@ def deploy_plan( daemon_probe=daemon_probe, ), _address_step(config, command_context), + _health_step(config, command_context), ) status = _overall_status(steps) return DeployPlan( @@ -279,6 +281,38 @@ def _address_step(config: GatewayConfig, commands: _CommandContext) -> DeploySte return DeployStep("addresses", "ready", commands.command("addresses list"), "addresses are known") +def _health_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep: + checks = health_plan(config) + if not checks: + return DeployStep("health", "ready", "-", "no published health checks are needed") + blocked = [check for check in checks if check.status == "blocked"] + review = [check for check in checks if check.status == "review"] + planned = [check for check in checks if check.status == "planned"] + if blocked: + status = "todo" if _missing_outputs(config) else "blocked" + return DeployStep( + "health", + status, + commands.command("health-plan"), + f"fix {len(blocked)} health check blocker(s)", + ) + if review: + return DeployStep( + "health", + "review", + commands.command("deploy apply --stage addresses --dry-run"), + f"resolve {len(review)} health check address review item(s)", + ) + if planned: + return DeployStep( + "health", + "todo", + commands.command("deploy apply --stage health --dry-run"), + f"run {len(planned)} published health check(s)", + ) + return DeployStep("health", "ready", commands.command("health-plan"), "health checks are ready") + + def _apply_preflight_step( config: GatewayConfig, commands: _CommandContext, diff --git a/docs/architecture.md b/docs/architecture.md index 42cb5f8..62cb019 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> Status: draft | Updated 2026-07-05 | Applies to: AMPG core +> Status: draft | Updated 2026-07-07 | Applies to: AMPG core AMPG is a compiler plus an ingress manager. The compiler turns one site into a protocol-neutral content graph, then renders protocol-specific outputs. The ingress @@ -146,6 +146,10 @@ remain separate stages. transport has produced them. It writes only the AMPG address registry; health verification remains a separate stage. +`ampg deploy apply --stage health` runs published fixture checks through the selected +transport after addresses are configured or captured. It reports pass/fail and does not +mutate deployment state. + 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 root. diff --git a/docs/daemon-management.md b/docs/daemon-management.md index e4236f8..103289d 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -1,6 +1,6 @@ # Daemon Management -> Status: draft | Updated 2026-07-06 | Applies to: protocol adapters +> Status: draft | Updated 2026-07-07 | Applies to: protocol adapters AMPG can use daemons that already exist on a server or create AMPG-owned daemons when the selected protocol is not running. This keeps simple deployments simple while avoiding @@ -64,6 +64,8 @@ python3 -m ampg --config gateway.toml deploy apply --stage start --dry-run --pro python3 -m ampg --config gateway.toml deploy apply --stage start --profile mobile-i2p --yes python3 -m ampg --config gateway.toml deploy apply --stage addresses --dry-run --profile mobile-i2p python3 -m ampg --config gateway.toml deploy apply --stage addresses --profile mobile-i2p --yes +python3 -m ampg --config gateway.toml deploy apply --stage health --dry-run --profile mobile-i2p +python3 -m ampg --config gateway.toml deploy apply --stage health --profile mobile-i2p --yes python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -108,6 +110,8 @@ configured, captured, derived, or placeholder. `deploy apply --stage addresses` `health-plan` prints fixture-based checks to run after services start. Published checks target real transport URLs; preview checks target local loopback preview endpoints. Missing output blocks the plan, while placeholder transport addresses are review items. +`deploy apply --stage health` runs the published checks after addresses are configured or +captured. `approvals list` prints generated artifact candidates and their digest status. `approve` requires `--all` or a `--kind` filter, then records approvals only for artifacts that @@ -146,6 +150,11 @@ files, capture addresses, or run health checks. Live mode requires `--yes` and writes captured transport addresses under `gateway.state_dir`. It does not rewrite daemon config or run health checks. +`deploy apply --stage health` is the fifth live apply stage. Dry-run mode prints +`AMPG_DEPLOY_HEALTH` rows with published fixture URLs and transport commands. Live mode +requires `--yes`, runs those commands, and reports pass/fail. It does not change daemon +config, service state, or address registries. + `--protocol` scopes operational commands to selected enabled protocols. A clearnet `adopt` failure will block a full activation run, but it will not block `apply --dry-run --protocol i2p` because clearnet is outside that selected run. diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index 0239472..2d4fb82 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -1,6 +1,6 @@ # Fixture Manifests -> Status: draft | Updated 2026-07-06 | Applies to: AMPG and AMPB compatibility checks +> Status: draft | Updated 2026-07-07 | Applies to: AMPG and AMPB compatibility checks AMPG writes deterministic fixture manifests so AMPB can verify that generated site variants route to the expected browser transport and profile. @@ -38,6 +38,7 @@ python3 -m ampg --config examples/wownero.gateway.toml addresses capture --profi python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/wownero.gateway.toml health-plan python3 -m ampg --config examples/wownero.gateway.toml health-plan --mode preview +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage health --dry-run --profile mobile-i2p ``` `build` emits the manifest after writing protocol outputs. `manifest` rewrites only the @@ -56,6 +57,8 @@ real Tor, I2P, Gemini, or other transport daemons are installed or adopted. `health-plan` consumes the same fixture contract. Published mode prints checks for real transport URLs. Preview mode prints loopback checks against generated preview URLs. +`deploy apply --stage health` runs the published checks after transport addresses are +configured or captured. ## Address Status @@ -66,8 +69,9 @@ verify transport selection without contacting the network. When a managed or adopted daemon writes an actual address, run `deploy apply --stage addresses --dry-run` before recording it. Captured addresses are stored under `gateway.state_dir` and replace placeholders in fixture manifests and -published health plans. Operators can also use `addresses capture` for a direct write or -`addresses set` to store an address manually. +published health plans. Operators can then run `deploy apply --stage health --dry-run` +to review the exact transport checks before live verification. Operators can also use +`addresses capture` for a direct write or `addresses set` to store an address manually. ## Interaction Policy diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index cac6cff..e3ddedd 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -1,6 +1,6 @@ # Wownero Integration -> Status: draft | Updated 2026-07-05 | Applies to: Wownero static site publishing +> Status: draft | Updated 2026-07-07 | Applies to: Wownero static site publishing The Wownero integration exercises AMPG against a real public static site. The source is the existing `wownero.org` website checked out next to this repository as @@ -141,8 +141,9 @@ daemon_policy = "auto" - `ampg build` writes a fixture manifest that AMPB can check. - `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live transport daemons are installed or adopted. -- `ampg deploy apply --stage state`, `--stage supervisor`, `--stage start`, and - `--stage addresses` stage Wownero transport publication without mixing concerns. +- `ampg deploy apply --stage state`, `--stage supervisor`, `--stage start`, + `--stage addresses`, and `--stage health` stage Wownero transport publication without + mixing concerns. - Tor output contains no `