Add state stage deploy apply

This commit is contained in:
jwinterm
2026-07-06 18:49:34 -04:00
parent a7500dacbc
commit 1dd601b0e4
8 changed files with 466 additions and 5 deletions
+14 -3
View File
@@ -56,15 +56,18 @@ python3 -m ampg --config gateway.toml build
python3 -m ampg --config gateway.toml doctor 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 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 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 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 `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 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 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 reachability hints. `apply --dry-run` prints the activation sequence and a preflight
preflight verdict without changing services. Live apply support will perform only verdict without changing services. `deploy apply --stage state` copies only approved
approved changes. managed-daemon artifacts into AMPG-owned state; later live stages will install and start
services after the same review gate.
## Interaction tiers ## 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 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 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 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
python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p 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 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 or started. The preflight gate reports `blocked`, `review`, or `ready` across activation
steps, managed-state copies, and supervisor actions. 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 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 lets a full site config build or activate only Tor, only I2P, or a selected subset without
unselected transports blocking the run. 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 health-plan`; review post-start transport checks.
- [ ] Run `ampg approvals approve --all` after reviewing generated artifacts. - [ ] Run `ampg approvals approve --all` after reviewing generated artifacts.
- [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate. - [ ] 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. - [ ] Run live apply after the preflight gate is ready.
+137
View File
@@ -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
+71
View File
@@ -21,6 +21,7 @@ from .addresses import (
effective_address_records, effective_address_records,
set_address, set_address,
) )
from .apply import StateApplyResult, apply_state
from .approvals import ( from .approvals import (
ACTIVATION_ARTIFACT_KIND, ACTIVATION_ARTIFACT_KIND,
GENERIC_PLATFORM, GENERIC_PLATFORM,
@@ -143,6 +144,32 @@ def main(argv: list[str] | None = None) -> int:
choices=PLATFORM_NAMES, choices=PLATFORM_NAMES,
help="Override platform detection for deploy planning.", 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_parser = subcommands.add_parser(
"dns", "dns",
help="Plan and check clearnet DNS records.", 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_next(index, next_step)
_print_deploy_summary(config, plan) _print_deploy_summary(config, plan)
return 1 if plan.status == "blocked" else 0 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 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: def _print_dns_record(record: DNSRecordPlan) -> None:
print( print(
"AMPG_DNS_RECORD " "AMPG_DNS_RECORD "
+6
View File
@@ -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 Blocked or unapproved preflight items stop mutation before any live apply path can touch
services. 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 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 blocked during activation, because AMPG must not point a transport at an unbuilt output
root. root.
+8
View File
@@ -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 apply --dry-run --write-artifacts
python3 -m ampg --config gateway.toml approvals list --profile mobile-i2p 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 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 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 approved artifacts into `gateway.state_dir` and the supervisor services that would be
registered or started. It still does not install or reload services. 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 `--protocol` scopes operational commands to selected enabled protocols. A clearnet
`adopt` failure will block a full activation run, but it will not block `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. `apply --dry-run --protocol i2p` because clearnet is outside that selected run.
+6 -2
View File
@@ -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 approved artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows
for the services that would be registered or started. The accompanying 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 `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, state or supervisor inputs still need review.
start daemons, capture public addresses, and then run published health checks.
`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.
+7
View File
@@ -141,6 +141,8 @@ daemon_policy = "auto"
- `ampg build` writes a fixture manifest that AMPB can check. - `ampg build` writes a fixture manifest that AMPB can check.
- `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live - `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live
transport daemons are installed or adopted. 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 `<script>` tags or inline event handlers. - Tor output contains no `<script>` tags or inline event handlers.
- Generated Gemini output has readable headings, paragraphs, and links. - Generated Gemini output has readable headings, paragraphs, and links.
- Generated Micron output fits terminal-first browsing. - Generated Micron output fits terminal-first browsing.
@@ -158,6 +160,10 @@ python3 -m ampg --config examples/wownero.gateway.toml manifest
python3 -m ampg --config examples/wownero.gateway.toml addresses list python3 -m ampg --config examples/wownero.gateway.toml addresses list
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints python3 -m ampg --config examples/wownero.gateway.toml preview endpoints
python3 -m ampg --config examples/wownero.gateway.toml preview manifest python3 -m ampg --config examples/wownero.gateway.toml preview manifest
python3 -m ampg --config examples/wownero.gateway.toml install-plan --profile mobile-i2p --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/i2p-only.gateway.toml plan python3 -m ampg --config examples/i2p-only.gateway.toml plan
python3 -m ampg --config examples/i2p-only.gateway.toml build python3 -m ampg --config examples/i2p-only.gateway.toml build
``` ```
@@ -170,3 +176,4 @@ python3 -m ampg --config examples/i2p-only.gateway.toml build
- [ ] Add Gemini conversion fixtures. - [ ] Add Gemini conversion fixtures.
- [ ] Add Reticulum/Micron conversion fixtures. - [ ] Add Reticulum/Micron conversion fixtures.
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable. - [ ] Enable I2P and Reticulum daemon management only after static outputs are stable.
- [ ] Copy only reviewed Wownero daemon artifacts into managed state.
+217
View File
@@ -0,0 +1,217 @@
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from ampg.cli import main
def _source(root: Path) -> Path:
source = root / "site"
source.mkdir()
(source / "index.html").write_text("<h1>Hello</h1>", encoding="utf-8")
return source
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()
def _init_i2p_config(root: Path) -> Path:
config_path = root / "gateway.toml"
status, _, error = _run_cli(
[
"--config",
str(config_path),
"init",
"site",
"example",
"--domain",
"example.test",
"--source",
str(_source(root)),
"--protocol",
"i2p",
]
)
if status != 0:
raise AssertionError(error)
output = root / "dist/example/i2p"
output.mkdir(parents=True)
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
return config_path
def _write_and_approve_artifacts(config_path: Path) -> None:
status, _, error = _run_cli(
[
"--config",
str(config_path),
"apply",
"--dry-run",
"--write-artifacts",
"--profile",
"mobile-i2p",
]
)
if status != 0:
raise AssertionError(error)
status, _, error = _run_cli(
[
"--config",
str(config_path),
"approvals",
"approve",
"--profile",
"mobile-i2p",
"--all",
]
)
if status != 0:
raise AssertionError(error)
class DeployApplyTest(unittest.TestCase):
def test_deploy_apply_state_blocks_unapproved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
status, output, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--dry-run",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(1, status)
self.assertEqual("", error)
self.assertIn("AMPG_DEPLOY_STATE", output)
self.assertIn("status=blocked", output)
self.assertIn("artifact is missing", output)
def test_deploy_apply_state_dry_run_after_approval(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
status, output, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--dry-run",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(0, status, error)
self.assertIn("AMPG_DEPLOY_STATE", output)
self.assertIn("status=planned", output)
self.assertIn("AMPG_DEPLOY_APPLY_SUMMARY stage=state mode=dry-run", output)
self.assertIn("blocked=0", output)
def test_deploy_apply_state_live_requires_yes(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
status, _, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(1, status)
self.assertIn("requires --yes", error)
def test_deploy_apply_state_blocks_stale_approved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
artifact = (
root / "dist/ampg-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, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--dry-run",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(1, status)
self.assertEqual("", error)
self.assertIn("status=blocked", output)
self.assertIn("artifact differs from current generator output", output)
def test_deploy_apply_state_live_copies_approved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
_write_and_approve_artifacts(config_path)
status, output, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"state",
"--profile",
"mobile-i2p",
"--yes",
]
)
daemon_config = root / ".ampg/state/example/i2p/i2pd-tunnels.conf"
loopback_config = root / ".ampg/state/example/i2p/nginx-loopback.conf"
daemon_config_exists = daemon_config.exists()
loopback_config_exists = loopback_config.exists()
self.assertEqual(0, status, error)
self.assertIn("status=written", output)
self.assertTrue(daemon_config_exists)
self.assertTrue(loopback_config_exists)
if __name__ == "__main__":
unittest.main()