mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add package deploy apply stage
This commit is contained in:
@@ -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 doctor
|
||||
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 approve --profile vps-full --all
|
||||
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
|
||||
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
|
||||
verdict without changing services. `deploy apply --stage state` copies approved
|
||||
managed-daemon config into AMPG-owned state. `deploy apply --stage supervisor` installs
|
||||
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.
|
||||
verdict without changing services. `deploy apply --stage packages` installs selected
|
||||
managed-daemon packages using structured platform commands. `deploy apply --stage state`
|
||||
copies approved managed-daemon config into AMPG-owned state. `deploy apply --stage
|
||||
supervisor` installs 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.
|
||||
|
||||
@@ -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 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 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 --profile mobile-i2p --yes
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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 approvals approve --all` after reviewing generated artifacts.
|
||||
- [ ] 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 supervisor --dry-run`; confirm approved service files.
|
||||
- [ ] Run `ampg deploy apply --stage start --dry-run`; confirm service-manager commands.
|
||||
|
||||
+169
-5
@@ -16,8 +16,10 @@ from .addresses import (
|
||||
from .config import GatewayConfig
|
||||
from .health import HealthCheck, health_plan
|
||||
from .install_plan import (
|
||||
InstallPackageAction,
|
||||
InstallStateCopy,
|
||||
InstallSupervisorAction,
|
||||
install_package_actions,
|
||||
install_state_copies,
|
||||
install_supervisor_actions,
|
||||
)
|
||||
@@ -61,6 +63,19 @@ class CommandRunResult:
|
||||
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)
|
||||
class StartApplyResult:
|
||||
site_id: str
|
||||
@@ -106,6 +121,62 @@ class HealthApplyResult:
|
||||
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(
|
||||
config: GatewayConfig,
|
||||
*,
|
||||
@@ -268,7 +339,7 @@ def apply_start(
|
||||
status="blocked",
|
||||
mode="live",
|
||||
return_code=run.return_code,
|
||||
message=_command_failure_message(run),
|
||||
message=_command_failure_message(run, label="start command"),
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -333,7 +404,7 @@ def apply_health(
|
||||
status="blocked",
|
||||
mode="live",
|
||||
return_code=run.return_code,
|
||||
message=_command_failure_message(run),
|
||||
message=_command_failure_message(run, label="health check command"),
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -350,6 +421,61 @@ def apply_health(
|
||||
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(
|
||||
config: GatewayConfig,
|
||||
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(
|
||||
check: HealthCheck,
|
||||
*,
|
||||
@@ -758,6 +905,23 @@ def _valid_service_name(service: str) -> bool:
|
||||
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:
|
||||
if provider.name not in {"android-termux", "linux-user"}:
|
||||
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
|
||||
if detail:
|
||||
return f"start command failed: {detail}"
|
||||
return "start command failed"
|
||||
return f"{label} failed: {detail}"
|
||||
return f"{label} failed"
|
||||
|
||||
|
||||
def format_command(command: tuple[str, ...]) -> str:
|
||||
|
||||
+42
-1
@@ -24,11 +24,13 @@ from .addresses import (
|
||||
from .apply import (
|
||||
AddressApplyResult,
|
||||
HealthApplyResult,
|
||||
PackageApplyResult,
|
||||
StateApplyResult,
|
||||
StartApplyResult,
|
||||
SupervisorApplyResult,
|
||||
apply_addresses,
|
||||
apply_health,
|
||||
apply_packages,
|
||||
apply_state,
|
||||
apply_start,
|
||||
apply_supervisor,
|
||||
@@ -164,7 +166,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", "health"),
|
||||
choices=("packages", "state", "supervisor", "start", "addresses", "health"),
|
||||
required=True,
|
||||
help="Deployment stage to apply.",
|
||||
)
|
||||
@@ -583,6 +585,29 @@ def _cmd_deploy(config, args) -> int:
|
||||
)
|
||||
return 1
|
||||
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":
|
||||
results = apply_state(
|
||||
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:
|
||||
print(
|
||||
"AMPG_DEPLOY_SUPERVISOR "
|
||||
|
||||
@@ -19,6 +19,7 @@ from .dns import dns_plan
|
||||
from .health import health_plan
|
||||
from .install_plan import (
|
||||
install_artifacts,
|
||||
install_package_actions,
|
||||
install_plan,
|
||||
install_state_copies,
|
||||
install_supervisor_actions,
|
||||
@@ -82,6 +83,12 @@ def deploy_plan(
|
||||
platform_provider=platform_provider,
|
||||
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),
|
||||
_apply_preflight_step(
|
||||
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(
|
||||
config: GatewayConfig,
|
||||
commands: _CommandContext,
|
||||
|
||||
+63
-4
@@ -66,6 +66,17 @@ class InstallSupervisorAction:
|
||||
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(
|
||||
config: GatewayConfig,
|
||||
*,
|
||||
@@ -120,6 +131,47 @@ def install_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(
|
||||
config: GatewayConfig,
|
||||
*,
|
||||
@@ -513,13 +565,20 @@ def _package_name(provider: PlatformProvider, daemon: str) -> str:
|
||||
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":
|
||||
return f"pkg install {package}"
|
||||
return ("pkg", "install", package)
|
||||
if provider.name == "linux-systemd":
|
||||
return f"sudo apt install {package}"
|
||||
return ("sudo", "apt", "install", package)
|
||||
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":
|
||||
return f"install {package} with the user package manager"
|
||||
return "-"
|
||||
|
||||
@@ -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
|
||||
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
|
||||
the same approved artifact plan, refuses missing, stale, or unapproved inputs, and copies
|
||||
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,
|
||||
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
|
||||
future live apply support.
|
||||
the staged live apply commands.
|
||||
|
||||
With `--write-artifacts`, install-plan writes managed config and supervisor files under
|
||||
the configured `plan_root`; those files are review artifacts, not installed service
|
||||
|
||||
+24
-10
@@ -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 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 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 --profile mobile-i2p --yes
|
||||
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,
|
||||
doctor, daemons, artifacts, addresses, and apply preflight. It prints the next commands
|
||||
to run and does not change files or services.
|
||||
doctor, daemons, packages, artifacts, addresses, and apply preflight. It prints the next
|
||||
commands to run and does not change files or services.
|
||||
|
||||
`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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
`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
|
||||
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
|
||||
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`,
|
||||
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
|
||||
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.
|
||||
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
|
||||
`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
|
||||
requires `--yes`, runs those commands, and reports pass/fail. It does not change daemon
|
||||
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.
|
||||
- `unknown`: render and plan only; daemon management is disabled.
|
||||
|
||||
Package commands in `install-plan` are review hints, not executed actions. Systemd hosts
|
||||
currently emit `apt`-style package hints, Termux emits `pkg`, macOS emits `brew`, and
|
||||
user-space Linux asks the operator to use the local user package manager.
|
||||
Package commands in `install-plan` are review hints. `deploy apply --stage packages` is
|
||||
the executable package stage for supported automatic backends: Systemd hosts use
|
||||
`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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
`--yes` for live writes, refuses unapproved or stale artifacts, writes only under
|
||||
`gateway.state_dir`, and leaves supervisor file installation, daemon start, address
|
||||
|
||||
@@ -141,9 +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`,
|
||||
`--stage addresses`, and `--stage health` stage Wownero transport publication without
|
||||
mixing concerns.
|
||||
- `ampg deploy apply --stage packages`, `--stage state`, `--stage supervisor`,
|
||||
`--stage start`, `--stage addresses`, and `--stage health` stage Wownero transport
|
||||
publication without mixing concerns.
|
||||
- Tor output contains no `<script>` tags or inline event handlers.
|
||||
- Generated Gemini output has readable headings, paragraphs, and links.
|
||||
- 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 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 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 supervisor --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 Reticulum/Micron conversion fixtures.
|
||||
- [ ] 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.
|
||||
- [ ] Capture Wownero transport addresses before published health checks.
|
||||
- [ ] Run published health checks through each selected transport.
|
||||
|
||||
@@ -8,6 +8,7 @@ from ampg.addresses import address_registry_path
|
||||
from ampg.apply import (
|
||||
CommandRunResult,
|
||||
apply_health,
|
||||
apply_packages,
|
||||
apply_start,
|
||||
apply_state,
|
||||
apply_supervisor,
|
||||
@@ -163,6 +164,94 @@ def _write_i2p_address_file(root: Path, value: str = "captureexample.b32.i2p") -
|
||||
|
||||
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
|
||||
@@ -5,6 +5,10 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
@@ -22,6 +26,10 @@ def _run_cli(args: list[str]) -> tuple[int, str, str]:
|
||||
return status, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
|
||||
def _missing_probe(adapter):
|
||||
return DaemonProbe(installed=False, running=False, executable_path=None)
|
||||
|
||||
|
||||
class DeployPlanTest(unittest.TestCase):
|
||||
def test_deploy_plan_guides_fresh_i2p_only_site(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -66,6 +74,39 @@ class DeployPlanTest(unittest.TestCase):
|
||||
self.assertIn("build --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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp).resolve()
|
||||
|
||||
Reference in New Issue
Block a user