Add package deploy apply stage

This commit is contained in:
jwinterm
2026-07-07 09:37:36 -04:00
parent 8b58bfb588
commit a143cd9342
11 changed files with 511 additions and 30 deletions
+15 -5
View File
@@ -56,6 +56,7 @@ python3 -m ampg --config gateway.toml dns plan --free-domain-hints
python3 -m ampg --config gateway.toml build 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 deploy apply --stage packages --dry-run --profile vps-full
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 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
@@ -70,11 +71,12 @@ python3 -m ampg --config gateway.toml deploy apply --stage health --dry-run --pr
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. `apply --dry-run` prints the activation sequence and a preflight reachability hints. `apply --dry-run` prints the activation sequence and a preflight
verdict without changing services. `deploy apply --stage state` copies approved verdict without changing services. `deploy apply --stage packages` installs selected
managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` installs managed-daemon packages using structured platform commands. `deploy apply --stage state`
approved supervisor files after state exists. `deploy apply --stage start` starts copies approved managed-daemon config into AMPG-owned state. `deploy apply --stage
AMPG-owned services after state and supervisor files are present. `deploy apply --stage supervisor` installs approved supervisor files after state exists. `deploy apply --stage
addresses` captures daemon-written public addresses into the address registry. 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 `deploy apply --stage health` verifies published fixture URLs through the selected
transport. transport.
@@ -140,6 +142,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 packages --dry-run --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage packages --profile mobile-i2p --yes
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 --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 deploy apply --stage state --profile mobile-i2p --yes
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p
@@ -198,6 +202,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 packages --dry-run` shows managed-daemon packages that can be
installed automatically for the selected platform. The live form requires `--yes`, runs
only allowlisted package-manager commands, and leaves config, services, keys, and
addresses unchanged. Adopted and external daemons are skipped.
`deploy apply --stage state --dry-run` shows approved managed-daemon artifacts that are `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 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 AMPG-owned state directories and copies approved artifact contents. It does not install
@@ -247,6 +256,7 @@ 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 packages --dry-run`; confirm package-manager commands.
- [ ] Run `ampg deploy apply --stage state --dry-run`; confirm approved state copies. - [ ] Run `ampg deploy apply --stage state --dry-run`; confirm approved state copies.
- [ ] Run `ampg deploy apply --stage supervisor --dry-run`; confirm approved service files. - [ ] 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 start --dry-run`; confirm service-manager commands.
+169 -5
View File
@@ -16,8 +16,10 @@ from .addresses import (
from .config import GatewayConfig from .config import GatewayConfig
from .health import HealthCheck, health_plan from .health import HealthCheck, health_plan
from .install_plan import ( from .install_plan import (
InstallPackageAction,
InstallStateCopy, InstallStateCopy,
InstallSupervisorAction, InstallSupervisorAction,
install_package_actions,
install_state_copies, install_state_copies,
install_supervisor_actions, install_supervisor_actions,
) )
@@ -61,6 +63,19 @@ class CommandRunResult:
stderr: str = "" stderr: str = ""
@dataclass(frozen=True)
class PackageApplyResult:
site_id: str
protocol: str
platform: str
package: str
status: str
mode: str
command: tuple[str, ...]
return_code: int | None
message: str
@dataclass(frozen=True) @dataclass(frozen=True)
class StartApplyResult: class StartApplyResult:
site_id: str site_id: str
@@ -106,6 +121,62 @@ class HealthApplyResult:
CommandRunner = Callable[[tuple[str, ...]], CommandRunResult] CommandRunner = Callable[[tuple[str, ...]], CommandRunResult]
def apply_packages(
config: GatewayConfig,
*,
dry_run: bool,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
command_runner: CommandRunner | None = None,
) -> list[PackageApplyResult]:
provider = platform_provider or detect_platform()
actions = install_package_actions(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
runner = command_runner or _run_command
results: list[PackageApplyResult] = []
for action in actions:
validation = _validate_package_action(action, dry_run=dry_run)
if validation.status in {"blocked", "skipped"}:
results.append(validation)
continue
if dry_run:
results.append(
_package_result(
action,
status="planned",
mode="dry-run",
return_code=None,
message="would install managed daemon package",
)
)
continue
run = runner(action.command)
if run.return_code != 0:
results.append(
_package_result(
action,
status="blocked",
mode="live",
return_code=run.return_code,
message=_command_failure_message(run, label="package install command"),
)
)
continue
results.append(
_package_result(
action,
status="installed",
mode="live",
return_code=run.return_code,
message="installed managed daemon package",
)
)
return results
def apply_state( def apply_state(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -268,7 +339,7 @@ def apply_start(
status="blocked", status="blocked",
mode="live", mode="live",
return_code=run.return_code, return_code=run.return_code,
message=_command_failure_message(run), message=_command_failure_message(run, label="start command"),
) )
) )
continue continue
@@ -333,7 +404,7 @@ def apply_health(
status="blocked", status="blocked",
mode="live", mode="live",
return_code=run.return_code, return_code=run.return_code,
message=_command_failure_message(run), message=_command_failure_message(run, label="health check command"),
) )
) )
continue continue
@@ -350,6 +421,61 @@ def apply_health(
return results return results
def _validate_package_action(
action: InstallPackageAction,
*,
dry_run: bool,
) -> PackageApplyResult:
mode = "dry-run" if dry_run else "live"
if action.status == "ready":
return _package_result(
action,
status="skipped",
mode=mode,
return_code=None,
message=action.message,
)
if action.status != "planned":
return _package_result(
action,
status="blocked",
mode=mode,
return_code=None,
message=action.message,
)
if not _valid_package_name(action.package):
return _package_result(
action,
status="blocked",
mode=mode,
return_code=None,
message=f"invalid package name: {action.package}",
)
if not action.command:
return _package_result(
action,
status="blocked",
mode=mode,
return_code=None,
message="selected platform does not have an automatic package install command",
)
if not _allowed_package_command(action):
return _package_result(
action,
status="blocked",
mode=mode,
return_code=None,
message="package install command is not allowlisted",
)
return _package_result(
action,
status="ready",
mode=mode,
return_code=None,
message="package install command passed safety checks",
)
def _validate_state_copy( def _validate_state_copy(
config: GatewayConfig, config: GatewayConfig,
copy: InstallStateCopy, copy: InstallStateCopy,
@@ -614,6 +740,27 @@ def _address_result(
) )
def _package_result(
action: InstallPackageAction,
*,
status: str,
mode: str,
return_code: int | None,
message: str,
) -> PackageApplyResult:
return PackageApplyResult(
site_id=action.site_id,
protocol=action.protocol,
platform=action.platform,
package=action.package,
status=status,
mode=mode,
command=action.command,
return_code=return_code,
message=message,
)
def _health_result( def _health_result(
check: HealthCheck, check: HealthCheck,
*, *,
@@ -758,6 +905,23 @@ def _valid_service_name(service: str) -> bool:
return all(char.isalnum() or char in {"-", "_", "."} for char in service) return all(char.isalnum() or char in {"-", "_", "."} for char in service)
def _valid_package_name(package: str) -> bool:
if not package or package == "-":
return False
return all(char.isalnum() or char in {"-", "_", ".", "+"} for char in package)
def _allowed_package_command(action: InstallPackageAction) -> bool:
package = action.package
if action.platform == "android-termux":
return action.command == ("pkg", "install", package)
if action.platform == "linux-systemd":
return action.command == ("sudo", "apt", "install", package)
if action.platform == "macos-launchd":
return action.command == ("brew", "install", package)
return False
def _chmod_supervisor_target(provider: PlatformProvider, target: Path) -> None: def _chmod_supervisor_target(provider: PlatformProvider, target: Path) -> None:
if provider.name not in {"android-termux", "linux-user"}: if provider.name not in {"android-termux", "linux-user"}:
return return
@@ -790,11 +954,11 @@ def _run_command(command: tuple[str, ...]) -> CommandRunResult:
) )
def _command_failure_message(run: CommandRunResult) -> str: def _command_failure_message(run: CommandRunResult, *, label: str = "command") -> str:
detail = run.stderr or run.stdout detail = run.stderr or run.stdout
if detail: if detail:
return f"start command failed: {detail}" return f"{label} failed: {detail}"
return "start command failed" return f"{label} failed"
def format_command(command: tuple[str, ...]) -> str: def format_command(command: tuple[str, ...]) -> str:
+42 -1
View File
@@ -24,11 +24,13 @@ from .addresses import (
from .apply import ( from .apply import (
AddressApplyResult, AddressApplyResult,
HealthApplyResult, HealthApplyResult,
PackageApplyResult,
StateApplyResult, StateApplyResult,
StartApplyResult, StartApplyResult,
SupervisorApplyResult, SupervisorApplyResult,
apply_addresses, apply_addresses,
apply_health, apply_health,
apply_packages,
apply_state, apply_state,
apply_start, apply_start,
apply_supervisor, apply_supervisor,
@@ -164,7 +166,7 @@ def main(argv: list[str] | None = None) -> int:
_add_target_selection(deploy_apply_parser) _add_target_selection(deploy_apply_parser)
deploy_apply_parser.add_argument( deploy_apply_parser.add_argument(
"--stage", "--stage",
choices=("state", "supervisor", "start", "addresses", "health"), choices=("packages", "state", "supervisor", "start", "addresses", "health"),
required=True, required=True,
help="Deployment stage to apply.", help="Deployment stage to apply.",
) )
@@ -583,6 +585,29 @@ def _cmd_deploy(config, args) -> int:
) )
return 1 return 1
platform_provider = _platform_override(config, args) platform_provider = _platform_override(config, args)
if args.stage == "packages":
results = apply_packages(
config,
dry_run=args.dry_run,
platform_provider=platform_provider,
)
for result in results:
_print_package_apply_result(result)
blocked = [result for result in results if result.status == "blocked"]
installed = [result for result in results if result.status == "installed"]
planned = [result for result in results if result.status == "planned"]
skipped = [result for result in results if result.status == "skipped"]
print(
"AMPG_DEPLOY_APPLY_SUMMARY "
f"stage=packages "
f"mode={'dry-run' if args.dry_run else 'live'} "
f"results={len(results)} "
f"planned={len(planned)} "
f"installed={len(installed)} "
f"skipped={len(skipped)} "
f"blocked={len(blocked)}"
)
return 1 if blocked else 0
if args.stage == "state": if args.stage == "state":
results = apply_state( results = apply_state(
config, config,
@@ -1171,6 +1196,22 @@ def _print_state_apply_result(result: StateApplyResult) -> None:
) )
def _print_package_apply_result(result: PackageApplyResult) -> None:
return_code = "-" if result.return_code is None else str(result.return_code)
print(
"AMPG_DEPLOY_PACKAGE "
f"site={result.site_id} "
f"protocol={result.protocol} "
f"platform={result.platform} "
f"package=\"{_quote(result.package)}\" "
f"mode={result.mode} "
f"status={result.status} "
f"return_code={return_code} "
f"command=\"{_quote(format_command(result.command))}\" "
f"message=\"{_quote(result.message)}\""
)
def _print_supervisor_apply_result(result: SupervisorApplyResult) -> None: def _print_supervisor_apply_result(result: SupervisorApplyResult) -> None:
print( print(
"AMPG_DEPLOY_SUPERVISOR " "AMPG_DEPLOY_SUPERVISOR "
+53
View File
@@ -19,6 +19,7 @@ from .dns import dns_plan
from .health import health_plan from .health import health_plan
from .install_plan import ( from .install_plan import (
install_artifacts, install_artifacts,
install_package_actions,
install_plan, install_plan,
install_state_copies, install_state_copies,
install_supervisor_actions, install_supervisor_actions,
@@ -82,6 +83,12 @@ def deploy_plan(
platform_provider=platform_provider, platform_provider=platform_provider,
daemon_probe=daemon_probe, daemon_probe=daemon_probe,
), ),
_package_step(
config,
command_context,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
),
_artifact_step(config, command_context, platform_provider=platform_provider), _artifact_step(config, command_context, platform_provider=platform_provider),
_apply_preflight_step( _apply_preflight_step(
config, config,
@@ -230,6 +237,52 @@ def _daemon_step(
) )
def _package_step(
config: GatewayConfig,
commands: _CommandContext,
*,
platform_provider: PlatformProvider | None,
daemon_probe: DaemonProbeFunc | None,
) -> DeployStep:
actions = install_package_actions(
config,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
)
if not actions:
return DeployStep("packages", "ready", "-", "no managed package installs are needed")
blocked = [action for action in actions if action.status == "blocked"]
planned = [action for action in actions if action.status == "planned"]
manual = [action for action in planned if not action.command]
if blocked:
return DeployStep(
"packages",
"blocked",
commands.command("install-plan", platform=True),
f"fix {len(blocked)} package planning blocker(s)",
)
if manual:
return DeployStep(
"packages",
"review",
commands.command("install-plan", platform=True),
f"install {len(manual)} package(s) with the local package manager",
)
if planned:
return DeployStep(
"packages",
"todo",
commands.command("deploy apply --stage packages --dry-run", platform=True),
f"install {len(planned)} managed daemon package(s)",
)
return DeployStep(
"packages",
"ready",
commands.command("deploy apply --stage packages --dry-run", platform=True),
"managed daemon packages are installed",
)
def _artifact_step( def _artifact_step(
config: GatewayConfig, config: GatewayConfig,
commands: _CommandContext, commands: _CommandContext,
+63 -4
View File
@@ -66,6 +66,17 @@ class InstallSupervisorAction:
message: str message: str
@dataclass(frozen=True)
class InstallPackageAction:
site_id: str
protocol: str
platform: str
package: str
status: str
command: tuple[str, ...]
message: str
def install_plan( def install_plan(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -120,6 +131,47 @@ def install_artifacts(
return artifacts return artifacts
def install_package_actions(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallPackageAction]:
provider = platform_provider or detect_platform()
steps = install_plan(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
actions: list[InstallPackageAction] = []
for step in steps:
if step.stage == "package":
actions.append(
InstallPackageAction(
site_id=step.site_id,
protocol=step.protocol,
platform=step.platform,
package=step.target,
status=step.status,
command=package_install_command(provider, step.target),
message=step.message,
)
)
elif step.status == "blocked":
actions.append(
InstallPackageAction(
site_id=step.site_id,
protocol=step.protocol,
platform=step.platform,
package=step.target,
status="blocked",
command=(),
message=step.message,
)
)
return actions
def write_install_artifacts( def write_install_artifacts(
config: GatewayConfig, config: GatewayConfig,
*, *,
@@ -513,13 +565,20 @@ def _package_name(provider: PlatformProvider, daemon: str) -> str:
return daemon return daemon
def _package_command(provider: PlatformProvider, package: str) -> str: def package_install_command(provider: PlatformProvider, package: str) -> tuple[str, ...]:
if provider.name == "android-termux": if provider.name == "android-termux":
return f"pkg install {package}" return ("pkg", "install", package)
if provider.name == "linux-systemd": if provider.name == "linux-systemd":
return f"sudo apt install {package}" return ("sudo", "apt", "install", package)
if provider.name == "macos-launchd": if provider.name == "macos-launchd":
return f"brew install {package}" return ("brew", "install", package)
return ()
def _package_command(provider: PlatformProvider, package: str) -> str:
command = package_install_command(provider, package)
if command:
return shlex.join(command)
if provider.name == "linux-user": if provider.name == "linux-user":
return f"install {package} with the user package manager" return f"install {package} with the user package manager"
return "-" return "-"
+5 -1
View File
@@ -131,6 +131,10 @@ 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 packages` is the narrow live stage for package installation.
It runs only structured package-manager commands for selected managed daemons. Adopted
or external daemons do not require package apply.
`ampg deploy apply --stage state` is the narrow live stage for managed state. It reads `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 the same approved artifact plan, refuses missing, stale, or unapproved inputs, and copies
only approved generated daemon config into AMPG-owned state. only approved generated daemon config into AMPG-owned state.
@@ -159,7 +163,7 @@ root.
`ampg install-plan` translates managed daemon decisions into package, state, config, `ampg install-plan` translates managed daemon decisions into package, state, config,
supervisor, and health-check steps. It does not install packages, create directories, or supervisor, and health-check steps. It does not install packages, create directories, or
start services. The output is a reviewable bridge between daemon ownership decisions and start services. The output is a reviewable bridge between daemon ownership decisions and
future live apply support. the staged live apply commands.
With `--write-artifacts`, install-plan writes managed config and supervisor files under With `--write-artifacts`, install-plan writes managed config and supervisor files under
the configured `plan_root`; those files are review artifacts, not installed service the configured `plan_root`; those files are review artifacts, not installed service
+24 -10
View File
@@ -56,6 +56,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 packages --dry-run --profile mobile-i2p
python3 -m ampg --config gateway.toml deploy apply --stage packages --profile mobile-i2p --yes
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 --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 deploy apply --stage state --profile mobile-i2p --yes
python3 -m ampg --config gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p python3 -m ampg --config gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p
@@ -70,8 +72,8 @@ python3 -m ampg --config gateway.toml doctor --platform android-termux
``` ```
`deploy plan` summarizes the current deployment in plain stages: source, build, DNS, `deploy plan` summarizes the current deployment in plain stages: source, build, DNS,
doctor, daemons, artifacts, addresses, and apply preflight. It prints the next commands doctor, daemons, packages, artifacts, addresses, and apply preflight. It prints the next
to run and does not change files or services. commands to run and does not change files or services.
`dns plan` prints clearnet DNS records and reachability hints. Static mode plans A/AAAA `dns plan` prints clearnet DNS records and reachability hints. Static mode plans A/AAAA
and `www` records. Dynamic mode supports a Dynamic DNS hostname and warns when apex and `www` records. Dynamic mode supports a Dynamic DNS hostname and warns when apex
@@ -99,6 +101,10 @@ Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopbac
artifacts. The generated daemon configs store runtime state, identity keys, logs, and artifacts. The generated daemon configs store runtime state, identity keys, logs, and
daemon-written addresses under `gateway.state_dir`. daemon-written addresses under `gateway.state_dir`.
`deploy apply --stage packages` installs selected managed-daemon packages with
structured, allowlisted platform commands. Adopted and external daemons are skipped.
Platforms without an automatic package backend remain manual review items.
`state-contract` prints the resolved managed-state paths for enabled protocols, including `state-contract` prints the resolved managed-state paths for enabled protocols, including
which paths are AMPG-owned, daemon-written, required, or sensitive. which paths are AMPG-owned, daemon-written, required, or sensitive.
@@ -127,30 +133,36 @@ 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 `deploy apply --stage packages` is the first live apply stage. Dry-run mode prints
`AMPG_DEPLOY_PACKAGE` rows. Live mode requires `--yes`, runs only structured
package-manager commands for selected managed daemons, and reports installed, skipped, or
blocked results. It does not write config, register services, start daemons, capture
addresses, or run health checks.
`deploy apply --stage state` is the second live apply stage. Dry-run mode prints
`AMPG_DEPLOY_STATE` rows. Live mode requires `--yes`, refuses unapproved or stale `AMPG_DEPLOY_STATE` rows. Live mode requires `--yes`, refuses unapproved or stale
artifacts, creates AMPG-owned state directories, and copies approved managed-daemon artifacts, creates AMPG-owned state directories, and copies approved managed-daemon
config into `gateway.state_dir`. It does not install packages, edit adopted daemon config into `gateway.state_dir`. It does not install packages, edit adopted daemon
configs, start services, or delete identity material. configs, start services, or delete identity material.
`deploy apply --stage supervisor` is the second live apply stage. Dry-run mode prints `deploy apply --stage supervisor` is the third live apply stage. Dry-run mode prints
`AMPG_DEPLOY_SUPERVISOR` rows. Live mode requires `--yes`, refuses unapproved or stale `AMPG_DEPLOY_SUPERVISOR` rows. Live mode requires `--yes`, refuses unapproved or stale
artifacts, requires the state stage to have copied managed config first, and writes artifacts, requires the state stage to have copied managed config first, and writes
AMPG-named service files for the selected platform. It does not install packages, start AMPG-named service files for the selected platform. It does not install packages, start
services, reload service managers, or delete service files. services, reload service managers, or delete service files.
`deploy apply --stage start` is the third live apply stage. Dry-run mode prints `deploy apply --stage start` is the fourth live apply stage. Dry-run mode prints
`AMPG_DEPLOY_START` rows with exact service-manager commands. Live mode requires `--yes`, `AMPG_DEPLOY_START` rows with exact service-manager commands. Live mode requires `--yes`,
requires both state and supervisor files to exist, and runs only structured platform requires both state and supervisor files to exist, and runs only structured platform
commands for AMPG-named services. It does not install packages, rewrite config, remove commands for AMPG-named services. It does not install packages, rewrite config, remove
files, capture addresses, or run health checks. files, capture addresses, or run health checks.
`deploy apply --stage addresses` is the fourth live apply stage. Dry-run mode prints `deploy apply --stage addresses` is the fifth live apply stage. Dry-run mode prints
`AMPG_DEPLOY_ADDRESS` rows for configured, skipped, missing, and capturable addresses. `AMPG_DEPLOY_ADDRESS` rows for configured, skipped, missing, and capturable addresses.
Live mode requires `--yes` and writes captured transport addresses under Live mode requires `--yes` and writes captured transport addresses under
`gateway.state_dir`. It does not rewrite daemon config or run health checks. `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 `deploy apply --stage health` is the sixth live apply stage. Dry-run mode prints
`AMPG_DEPLOY_HEALTH` rows with published fixture URLs and transport commands. Live mode `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 requires `--yes`, runs those commands, and reports pass/fail. It does not change daemon
config, service state, or address registries. config, service state, or address registries.
@@ -171,9 +183,11 @@ Platform providers describe how AMPG may supervise managed daemons:
- `android-termux`: Termux-style user-space daemons for mobile/server experiments. - `android-termux`: Termux-style user-space daemons for mobile/server experiments.
- `unknown`: render and plan only; daemon management is disabled. - `unknown`: render and plan only; daemon management is disabled.
Package commands in `install-plan` are review hints, not executed actions. Systemd hosts Package commands in `install-plan` are review hints. `deploy apply --stage packages` is
currently emit `apt`-style package hints, Termux emits `pkg`, macOS emits `brew`, and the executable package stage for supported automatic backends: Systemd hosts use
user-space Linux asks the operator to use the local user package manager. `sudo apt install`, Termux uses `pkg install`, and macOS uses `brew install`. User-space
Linux asks the operator to use the local package manager until AMPG has a concrete
backend for that environment.
## Adapter notes ## Adapter notes
+5 -1
View File
@@ -1,6 +1,6 @@
# State Contract # State Contract
> Status: draft | Updated 2026-07-06 | Applies to: managed daemon state > Status: draft | Updated 2026-07-07 | Applies to: managed daemon state
AMPG keeps managed daemon state under `gateway.state_dir`. The state contract describes 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 which files AMPG owns, which files daemons or adapters write, which paths may contain
@@ -48,6 +48,10 @@ 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. state or supervisor inputs still need review.
`deploy apply --stage packages` performs package installation only. It uses structured
platform package-manager commands for selected managed daemons and does not write AMPG
state, service files, generated config, or address registries.
`deploy apply --stage state` performs the approved copy portion only. It requires `deploy apply --stage state` performs the approved copy portion only. It requires
`--yes` for live writes, refuses unapproved or stale artifacts, writes only under `--yes` for live writes, refuses unapproved or stale artifacts, writes only under
`gateway.state_dir`, and leaves supervisor file installation, daemon start, address `gateway.state_dir`, and leaves supervisor file installation, daemon start, address
+5 -3
View File
@@ -141,9 +141,9 @@ 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`, `--stage supervisor`, `--stage start`, - `ampg deploy apply --stage packages`, `--stage state`, `--stage supervisor`,
`--stage addresses`, and `--stage health` stage Wownero transport publication without `--stage start`, `--stage addresses`, and `--stage health` stage Wownero transport
mixing concerns. publication without mixing concerns.
- 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.
@@ -165,6 +165,7 @@ 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 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 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 packages --dry-run --profile mobile-i2p
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 --dry-run --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage supervisor --dry-run --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage start --dry-run --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage start --dry-run --profile mobile-i2p
@@ -182,6 +183,7 @@ 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.
- [ ] Review package install commands for selected managed Wownero transports.
- [ ] Start only AMPG-owned Wownero transport services after reviewed files are applied. - [ ] Start only AMPG-owned Wownero transport services after reviewed files are applied.
- [ ] Capture Wownero transport addresses before published health checks. - [ ] Capture Wownero transport addresses before published health checks.
- [ ] Run published health checks through each selected transport. - [ ] Run published health checks through each selected transport.
+89
View File
@@ -8,6 +8,7 @@ from ampg.addresses import address_registry_path
from ampg.apply import ( from ampg.apply import (
CommandRunResult, CommandRunResult,
apply_health, apply_health,
apply_packages,
apply_start, apply_start,
apply_state, apply_state,
apply_supervisor, apply_supervisor,
@@ -163,6 +164,94 @@ def _write_i2p_address_file(root: Path, value: str = "captureexample.b32.i2p") -
class DeployApplyTest(unittest.TestCase): class DeployApplyTest(unittest.TestCase):
def test_deploy_apply_packages_dry_run_plans_termux_installs(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
config = load_config(config_path)
results = apply_packages(
config,
dry_run=True,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
)
self.assertTrue(all(result.status == "planned" for result in results))
self.assertIn(("pkg", "install", "i2pd"), [result.command for result in results])
self.assertIn(("pkg", "install", "nginx"), [result.command for result in results])
def test_deploy_apply_packages_live_invokes_runner(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
config = load_config(config_path)
commands: list[tuple[str, ...]] = []
def runner(command: tuple[str, ...]) -> CommandRunResult:
commands.append(command)
return CommandRunResult(return_code=0)
results = apply_packages(
config,
dry_run=False,
platform_provider=_termux_provider(root),
daemon_probe=_missing_probe,
command_runner=runner,
)
self.assertTrue(all(result.status == "installed" for result in results))
self.assertIn(("pkg", "install", "i2pd"), commands)
self.assertIn(("pkg", "install", "nginx"), commands)
def test_deploy_apply_packages_blocks_manual_linux_user_install(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
config = load_config(config_path)
results = apply_packages(
config,
dry_run=True,
platform_provider=_linux_user_provider(root),
daemon_probe=_missing_probe,
)
self.assertTrue(any(result.status == "blocked" for result in results))
self.assertTrue(
any("automatic package install command" in result.message for result in results)
)
def test_deploy_apply_packages_cli_prints_summary(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _init_i2p_config(root)
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
'daemon_policy = "auto"',
'daemon_policy = "manage"',
),
encoding="utf-8",
)
status, output, error = _run_cli(
[
"--config",
str(config_path),
"deploy",
"apply",
"--stage",
"packages",
"--dry-run",
"--profile",
"mobile-i2p",
]
)
self.assertEqual(0, status, error)
self.assertIn("AMPG_DEPLOY_PACKAGE", output)
self.assertIn("AMPG_DEPLOY_APPLY_SUMMARY stage=packages mode=dry-run", output)
def test_deploy_apply_state_blocks_unapproved_artifacts(self): def test_deploy_apply_state_blocks_unapproved_artifacts(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve() root = Path(tmp).resolve()
+41
View File
@@ -5,6 +5,10 @@ import unittest
from pathlib import Path from pathlib import Path
from ampg.cli import main from ampg.cli import main
from ampg.config import load_config
from ampg.deploy import deploy_plan
from ampg.platforms import platform_by_name
from ampg.status import DaemonProbe
def _source(root: Path) -> Path: def _source(root: Path) -> Path:
@@ -22,6 +26,10 @@ def _run_cli(args: list[str]) -> tuple[int, str, str]:
return status, stdout.getvalue(), stderr.getvalue() return status, stdout.getvalue(), stderr.getvalue()
def _missing_probe(adapter):
return DaemonProbe(installed=False, running=False, executable_path=None)
class DeployPlanTest(unittest.TestCase): class DeployPlanTest(unittest.TestCase):
def test_deploy_plan_guides_fresh_i2p_only_site(self): def test_deploy_plan_guides_fresh_i2p_only_site(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -66,6 +74,39 @@ class DeployPlanTest(unittest.TestCase):
self.assertIn("build --profile mobile-i2p", output) self.assertIn("build --profile mobile-i2p", output)
self.assertIn("deploy apply --stage addresses --dry-run --profile mobile-i2p", output) self.assertIn("deploy apply --stage addresses --dry-run --profile mobile-i2p", output)
def test_deploy_plan_guides_package_apply_for_missing_managed_daemons(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = root / "gateway.toml"
init_status, _, init_error = _run_cli(
[
"--config",
str(config_path),
"init",
"site",
"example",
"--domain",
"example.test",
"--source",
str(_source(root)),
"--protocol",
"i2p",
]
)
self.assertEqual(0, init_status, init_error)
plan = deploy_plan(
load_config(config_path),
profile="mobile-i2p",
platform_provider=platform_by_name("android-termux"),
daemon_probe=_missing_probe,
)
packages = next(step for step in plan.steps if step.stage == "packages")
self.assertEqual("todo", packages.status)
self.assertIn("deploy apply --stage packages --dry-run", packages.command)
self.assertIn("managed daemon package", packages.message)
def test_deploy_plan_shows_dns_review_for_clearnet_site(self): def test_deploy_plan_shows_dns_review_for_clearnet_site(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve() root = Path(tmp).resolve()