diff --git a/README.md b/README.md index 8ad3a4c..a6852e6 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,18 @@ python3 -m ampg --config gateway.toml build python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts python3 -m ampg --config gateway.toml approvals list --profile vps-full +python3 -m ampg --config gateway.toml approvals approve --profile vps-full --all python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full +python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --profile vps-full ``` `init site` writes a readable `gateway.toml` with selected transports enabled and common transports left as one-line toggles. `deploy plan` condenses the lower-level checks into clear next steps. `dns plan` covers static DNS, Dynamic DNS, and behind-router -reachability hints. The current `apply --dry-run` prints the activation sequence and a -preflight verdict without changing services. Live apply support will perform only -approved changes. +reachability hints. `apply --dry-run` prints the activation sequence and a preflight +verdict without changing services. `deploy apply --stage state` copies only approved +managed-daemon artifacts into AMPG-owned state; later live stages will install and start +services after the same review gate. ## Interaction tiers @@ -126,6 +129,8 @@ python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protoco 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 deploy apply --stage state --dry-run --profile mobile-i2p +python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --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 @@ -173,6 +178,11 @@ would be copied into managed state and the supervisor services that would be reg or started. The preflight gate reports `blocked`, `review`, or `ready` across activation steps, managed-state copies, and supervisor actions. +`deploy apply --stage state --dry-run` shows approved managed-daemon artifacts that are +ready to copy into `gateway.state_dir`. The live form requires `--yes`; it creates only +AMPG-owned state directories and copies approved artifact contents. It does not install +packages, change adopted daemons, start services, or remove keys. + 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 unselected transports blocking the run. @@ -193,4 +203,5 @@ Explicit `--protocol` and `--platform` flags override the profile when present. - [ ] 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 `ampg deploy apply --stage state --dry-run`; confirm approved state copies. - [ ] Run live apply after the preflight gate is ready. diff --git a/ampg/apply.py b/ampg/apply.py new file mode 100644 index 0000000..262f551 --- /dev/null +++ b/ampg/apply.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import shutil + +from .config import GatewayConfig +from .install_plan import InstallStateCopy, install_state_copies +from .platforms import PlatformProvider +from .state_contract import gateway_state_root +from .status import DaemonProbeFunc + + +@dataclass(frozen=True) +class StateApplyResult: + site_id: str + protocol: str + platform: str + kind: str + source: Path + target: Path + status: str + mode: str + message: str + + +def apply_state( + config: GatewayConfig, + *, + dry_run: bool, + platform_provider: PlatformProvider | None = None, + daemon_probe: DaemonProbeFunc | None = None, +) -> list[StateApplyResult]: + copies = install_state_copies( + config, + platform_provider=platform_provider, + daemon_probe=daemon_probe, + ) + results: list[StateApplyResult] = [] + for copy in copies: + validation = _validate_state_copy(config, copy, dry_run=dry_run) + if validation.status == "blocked": + results.append(validation) + continue + if not dry_run: + copy.target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(copy.source, copy.target) + results.append( + StateApplyResult( + site_id=copy.site_id, + protocol=copy.protocol, + platform=copy.platform, + kind=copy.kind, + source=copy.source, + target=copy.target, + status="planned" if dry_run else "written", + mode="dry-run" if dry_run else "live", + message=( + "would copy approved artifact into AMPG-owned state" + if dry_run + else "copied approved artifact into AMPG-owned state" + ), + ) + ) + return results + + +def _validate_state_copy( + config: GatewayConfig, + copy: InstallStateCopy, + *, + dry_run: bool, +) -> StateApplyResult: + if copy.status != "planned": + return _result( + copy, + status="blocked", + mode="dry-run" if dry_run else "live", + message=copy.message, + ) + state_root = gateway_state_root(config).resolve() + target = copy.target.resolve() + if not _is_relative_to(target, state_root): + return _result( + copy, + status="blocked", + mode="dry-run" if dry_run else "live", + message=f"target is outside AMPG state root: {target}", + ) + if not copy.source.exists(): + return _result( + copy, + status="blocked", + mode="dry-run" if dry_run else "live", + message="approved source artifact is missing", + ) + if copy.source.is_dir(): + return _result( + copy, + status="blocked", + mode="dry-run" if dry_run else "live", + message="approved source artifact is a directory", + ) + return _result( + copy, + status="ready", + mode="dry-run" if dry_run else "live", + message="state copy passed safety checks", + ) + + +def _result( + copy: InstallStateCopy, + *, + status: str, + mode: str, + message: str, +) -> StateApplyResult: + return StateApplyResult( + site_id=copy.site_id, + protocol=copy.protocol, + platform=copy.platform, + kind=copy.kind, + source=copy.source, + target=copy.target, + status=status, + mode=mode, + message=message, + ) + + +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True diff --git a/ampg/cli.py b/ampg/cli.py index 25eec20..fe9810a 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -21,6 +21,7 @@ from .addresses import ( effective_address_records, set_address, ) +from .apply import StateApplyResult, apply_state from .approvals import ( ACTIVATION_ARTIFACT_KIND, GENERIC_PLATFORM, @@ -143,6 +144,32 @@ def main(argv: list[str] | None = None) -> int: choices=PLATFORM_NAMES, help="Override platform detection for deploy planning.", ) + deploy_apply_parser = deploy_subcommands.add_parser( + "apply", + help="Apply an approved deployment stage.", + ) + _add_target_selection(deploy_apply_parser) + deploy_apply_parser.add_argument( + "--stage", + choices=("state",), + required=True, + help="Deployment stage to apply.", + ) + deploy_apply_parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing files.", + ) + deploy_apply_parser.add_argument( + "--yes", + action="store_true", + help="Required for live stage application.", + ) + deploy_apply_parser.add_argument( + "--platform", + choices=PLATFORM_NAMES, + help="Override platform detection for deploy apply.", + ) dns_parser = subcommands.add_parser( "dns", help="Plan and check clearnet DNS records.", @@ -530,6 +557,35 @@ def _cmd_deploy(config, args) -> int: _print_deploy_next(index, next_step) _print_deploy_summary(config, plan) return 1 if plan.status == "blocked" else 0 + if args.deploy_command == "apply": + if not args.dry_run and not args.yes: + print( + 'AMPG_DEPLOY_APPLY status=error message="live deploy apply requires --yes or rerun with --dry-run"', + file=sys.stderr, + ) + return 1 + platform_provider = _platform_override(config, args) + if args.stage == "state": + results = apply_state( + config, + dry_run=args.dry_run, + platform_provider=platform_provider, + ) + for result in results: + _print_state_apply_result(result) + blocked = [result for result in results if result.status == "blocked"] + written = [result for result in results if result.status == "written"] + planned = [result for result in results if result.status == "planned"] + print( + "AMPG_DEPLOY_APPLY_SUMMARY " + f"stage=state " + f"mode={'dry-run' if args.dry_run else 'live'} " + f"results={len(results)} " + f"planned={len(planned)} " + f"written={len(written)} " + f"blocked={len(blocked)}" + ) + return 1 if blocked else 0 return 1 @@ -1000,6 +1056,21 @@ def _print_deploy_summary(config, plan: DeployPlan) -> None: ) +def _print_state_apply_result(result: StateApplyResult) -> None: + print( + "AMPG_DEPLOY_STATE " + f"site={result.site_id} " + f"protocol={result.protocol} " + f"platform={result.platform} " + f"kind={result.kind} " + f"mode={result.mode} " + f"source=\"{_quote(str(result.source))}\" " + f"target=\"{_quote(str(result.target))}\" " + f"status={result.status} " + f"message=\"{_quote(result.message)}\"" + ) + + def _print_dns_record(record: DNSRecordPlan) -> None: print( "AMPG_DNS_RECORD " diff --git a/docs/architecture.md b/docs/architecture.md index a384e7a..0881d69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,6 +131,12 @@ managed state and planned supervisor start/reload actions. The command emits an Blocked or unapproved preflight items stop mutation before any live apply path can touch services. +`ampg deploy apply --stage state` is the narrow live stage for managed state. It reads +the same approved artifact plan, refuses missing, stale, or unapproved inputs, and copies +only approved generated daemon config into AMPG-owned state. Service installation, +supervisor registration, daemon start, address capture, and health verification remain +separate stages. + 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 33d21cf..2df2681 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -55,6 +55,8 @@ 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 deploy apply --stage state --dry-run --profile mobile-i2p +python3 -m ampg --config gateway.toml deploy apply --stage state --profile mobile-i2p --yes python3 -m ampg --config gateway.toml doctor --platform android-termux ``` @@ -112,6 +114,12 @@ reviewable config snippets to the configured plan root, then prints the copies f approved artifacts into `gateway.state_dir` and the supervisor services that would be registered or started. It still does not install or reload services. +`deploy apply --stage state` is the first live apply stage. Dry-run mode prints +`AMPG_DEPLOY_STATE` rows. Live mode requires `--yes`, refuses unapproved or stale +artifacts, creates AMPG-owned state directories, and copies approved managed-daemon +config into `gateway.state_dir`. It does not install packages, edit adopted daemon +configs, start services, or delete identity material. + `--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/state-contract.md b/docs/state-contract.md index 092cd65..492c168 100644 --- a/docs/state-contract.md +++ b/docs/state-contract.md @@ -46,5 +46,9 @@ AMPG must never delete sensitive contract paths without an explicit destructive 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 approved copies, -start daemons, capture public addresses, and then run published health checks. +state or supervisor inputs still need review. + +`deploy apply --stage state` performs the approved copy portion only. It requires +`--yes` for live writes, refuses unapproved or stale artifacts, writes only under +`gateway.state_dir`, and leaves daemon start, address capture, and published health +checks for later stages. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index baa2048..4f9a239 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -141,6 +141,8 @@ 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 --dry-run` shows approved managed-daemon config copies + before any Wownero transport service is started. - Tor output contains no `