mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add guided deploy plan
This commit is contained in:
@@ -50,6 +50,7 @@ python3 -m ampg --config gateway.toml init site wownero \
|
|||||||
--source ../wownero.org-website \
|
--source ../wownero.org-website \
|
||||||
--preset full
|
--preset full
|
||||||
|
|
||||||
|
python3 -m ampg --config gateway.toml deploy plan --profile vps-full
|
||||||
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
|
||||||
@@ -58,9 +59,10 @@ python3 -m ampg --config gateway.toml apply --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. The current `apply --dry-run` prints the activation
|
transports left as one-line toggles. `deploy plan` condenses the lower-level checks into
|
||||||
sequence and a preflight verdict without changing services. Live apply support will
|
clear next steps. The current `apply --dry-run` prints the activation sequence and a
|
||||||
perform only approved changes.
|
preflight verdict without changing services. Live apply support will perform only
|
||||||
|
approved changes.
|
||||||
|
|
||||||
## Interaction tiers
|
## Interaction tiers
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ The current implementation is dependency-free Python:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full
|
python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml deploy plan --profile vps-full
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml plan
|
python3 -m ampg --config examples/wownero.gateway.toml plan
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts
|
python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml status
|
python3 -m ampg --config examples/wownero.gateway.toml status
|
||||||
|
|||||||
+79
@@ -35,6 +35,7 @@ from .approvals import (
|
|||||||
from .audit import audit_gateway
|
from .audit import audit_gateway
|
||||||
from .build import build_gateway
|
from .build import build_gateway
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
|
from .deploy import DeployPlan, DeployStep, DeployNextStep, deploy_plan
|
||||||
from .docsgen import generate_docs
|
from .docsgen import generate_docs
|
||||||
from .health import HealthCheck, blocked_health_checks, health_plan
|
from .health import HealthCheck, blocked_health_checks, health_plan
|
||||||
from .install_plan import (
|
from .install_plan import (
|
||||||
@@ -118,6 +119,21 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Replace an existing config file.",
|
help="Replace an existing config file.",
|
||||||
)
|
)
|
||||||
|
deploy_parser = subcommands.add_parser(
|
||||||
|
"deploy",
|
||||||
|
help="Plan or run a guided site deployment.",
|
||||||
|
)
|
||||||
|
deploy_subcommands = deploy_parser.add_subparsers(dest="deploy_command", required=True)
|
||||||
|
deploy_plan_parser = deploy_subcommands.add_parser(
|
||||||
|
"plan",
|
||||||
|
help="Show the simplest next steps for this deployment.",
|
||||||
|
)
|
||||||
|
_add_target_selection(deploy_plan_parser)
|
||||||
|
deploy_plan_parser.add_argument(
|
||||||
|
"--platform",
|
||||||
|
choices=PLATFORM_NAMES,
|
||||||
|
help="Override platform detection for deploy planning.",
|
||||||
|
)
|
||||||
plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.")
|
plan_parser = subcommands.add_parser("plan", help="Print a dry-run plan.")
|
||||||
_add_target_selection(plan_parser)
|
_add_target_selection(plan_parser)
|
||||||
plan_parser.add_argument(
|
plan_parser.add_argument(
|
||||||
@@ -375,6 +391,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _cmd_status(config, args)
|
return _cmd_status(config, args)
|
||||||
if args.command == "doctor":
|
if args.command == "doctor":
|
||||||
return _cmd_doctor(config, args)
|
return _cmd_doctor(config, args)
|
||||||
|
if args.command == "deploy":
|
||||||
|
return _cmd_deploy(config, args)
|
||||||
if args.command == "apply":
|
if args.command == "apply":
|
||||||
return _cmd_apply(config, args)
|
return _cmd_apply(config, args)
|
||||||
if args.command == "approvals":
|
if args.command == "approvals":
|
||||||
@@ -451,6 +469,25 @@ def _cmd_init(args) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_deploy(config, args) -> int:
|
||||||
|
if args.deploy_command == "plan":
|
||||||
|
platform_provider = _platform_override(config, args)
|
||||||
|
plan = deploy_plan(
|
||||||
|
config,
|
||||||
|
profile=getattr(args, "profile", None),
|
||||||
|
protocols=parse_protocol_filters(getattr(args, "protocol", ())),
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
platform_name=platform_provider.name if platform_provider else None,
|
||||||
|
)
|
||||||
|
for step in plan.steps:
|
||||||
|
_print_deploy_step(step)
|
||||||
|
for index, next_step in enumerate(plan.next_steps, start=1):
|
||||||
|
_print_deploy_next(index, next_step)
|
||||||
|
_print_deploy_summary(config, plan)
|
||||||
|
return 1 if plan.status == "blocked" else 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _cmd_apply(config, args) -> int:
|
def _cmd_apply(config, args) -> int:
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
print(
|
print(
|
||||||
@@ -841,6 +878,48 @@ def _print_apply_preflight_item(item: ApplyPreflightItem) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_deploy_step(step: DeployStep) -> None:
|
||||||
|
print(
|
||||||
|
"AMPG_DEPLOY_STEP "
|
||||||
|
f"stage={step.stage} "
|
||||||
|
f"status={step.status} "
|
||||||
|
f"command=\"{_quote(step.command)}\" "
|
||||||
|
f"message=\"{_quote(step.message)}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_deploy_next(index: int, next_step: DeployNextStep) -> None:
|
||||||
|
print(
|
||||||
|
"AMPG_DEPLOY_NEXT "
|
||||||
|
f"step={index} "
|
||||||
|
f"stage={next_step.stage} "
|
||||||
|
f"command=\"{_quote(next_step.command)}\" "
|
||||||
|
f"message=\"{_quote(next_step.message)}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_deploy_summary(config, plan: DeployPlan) -> None:
|
||||||
|
statuses = [step.status for step in plan.steps]
|
||||||
|
protocol_count = sum(
|
||||||
|
1
|
||||||
|
for site in config.sites
|
||||||
|
for protocol in site.protocols.values()
|
||||||
|
if protocol.enabled
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"AMPG_DEPLOY_SUMMARY "
|
||||||
|
f"status={plan.status} "
|
||||||
|
f"sites={len(config.sites)} "
|
||||||
|
f"protocols={protocol_count} "
|
||||||
|
f"ready={statuses.count('ready')} "
|
||||||
|
f"todo={statuses.count('todo')} "
|
||||||
|
f"review={statuses.count('review')} "
|
||||||
|
f"blocked={statuses.count('blocked')} "
|
||||||
|
f"skipped={statuses.count('skipped')} "
|
||||||
|
f"message=\"{_quote(plan.message)}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_approval_check(check: ApprovalCheck) -> None:
|
def _print_approval_check(check: ApprovalCheck) -> None:
|
||||||
candidate = check.candidate
|
candidate = check.candidate
|
||||||
print(
|
print(
|
||||||
|
|||||||
+397
@@ -0,0 +1,397 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
from .activation import activation_steps, apply_preflight
|
||||||
|
from .addresses import effective_address_records
|
||||||
|
from .approvals import (
|
||||||
|
ACTIVATION_ARTIFACT_KIND,
|
||||||
|
GENERIC_PLATFORM,
|
||||||
|
ApprovalInput,
|
||||||
|
approval_check,
|
||||||
|
load_approval_registry,
|
||||||
|
)
|
||||||
|
from .config import GatewayConfig
|
||||||
|
from .install_plan import (
|
||||||
|
install_artifacts,
|
||||||
|
install_plan,
|
||||||
|
install_state_copies,
|
||||||
|
install_supervisor_actions,
|
||||||
|
)
|
||||||
|
from .plan import plan_artifacts
|
||||||
|
from .platforms import PlatformProvider
|
||||||
|
from .status import DaemonProbeFunc, doctor_gateway
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeployStep:
|
||||||
|
stage: str
|
||||||
|
status: str
|
||||||
|
command: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeployNextStep:
|
||||||
|
stage: str
|
||||||
|
command: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeployPlan:
|
||||||
|
status: str
|
||||||
|
steps: tuple[DeployStep, ...]
|
||||||
|
next_steps: tuple[DeployNextStep, ...]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_plan(
|
||||||
|
config: GatewayConfig,
|
||||||
|
*,
|
||||||
|
profile: str | None = None,
|
||||||
|
protocols: tuple[str, ...] = (),
|
||||||
|
platform_provider: PlatformProvider | None = None,
|
||||||
|
platform_name: str | None = None,
|
||||||
|
daemon_probe: DaemonProbeFunc | None = None,
|
||||||
|
) -> DeployPlan:
|
||||||
|
command_context = _CommandContext(
|
||||||
|
config_path=config.config_path,
|
||||||
|
profile=profile,
|
||||||
|
protocols=protocols,
|
||||||
|
platform=platform_name,
|
||||||
|
)
|
||||||
|
steps = (
|
||||||
|
_source_step(config),
|
||||||
|
_build_step(config, command_context),
|
||||||
|
_dns_step(config),
|
||||||
|
_doctor_step(
|
||||||
|
config,
|
||||||
|
command_context,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
),
|
||||||
|
_daemon_step(
|
||||||
|
config,
|
||||||
|
command_context,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
),
|
||||||
|
_artifact_step(config, command_context, platform_provider=platform_provider),
|
||||||
|
_address_step(config, command_context),
|
||||||
|
_apply_preflight_step(
|
||||||
|
config,
|
||||||
|
command_context,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status = _overall_status(steps)
|
||||||
|
return DeployPlan(
|
||||||
|
status=status,
|
||||||
|
steps=steps,
|
||||||
|
next_steps=_next_steps(steps),
|
||||||
|
message=_overall_message(status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CommandContext:
|
||||||
|
config_path: Path
|
||||||
|
profile: str | None
|
||||||
|
protocols: tuple[str, ...]
|
||||||
|
platform: str | None
|
||||||
|
|
||||||
|
def command(self, subcommand: str, *, platform: bool = False) -> str:
|
||||||
|
parts = ["python3", "-m", "ampg", "--config", str(self.config_path), *subcommand.split()]
|
||||||
|
if self.profile:
|
||||||
|
parts.extend(["--profile", self.profile])
|
||||||
|
for protocol in self.protocols:
|
||||||
|
parts.extend(["--protocol", protocol])
|
||||||
|
if platform and self.platform:
|
||||||
|
parts.extend(["--platform", self.platform])
|
||||||
|
return " ".join(shlex.quote(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_step(config: GatewayConfig) -> DeployStep:
|
||||||
|
missing = [site for site in config.sites if not site.source.path.exists()]
|
||||||
|
not_dirs = [
|
||||||
|
site
|
||||||
|
for site in config.sites
|
||||||
|
if site.source.path.exists() and not site.source.path.is_dir()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
names = ", ".join(site.id for site in missing)
|
||||||
|
return DeployStep("source", "blocked", "-", f"source path is missing for {names}")
|
||||||
|
if not_dirs:
|
||||||
|
names = ", ".join(site.id for site in not_dirs)
|
||||||
|
return DeployStep("source", "blocked", "-", f"source path is not a directory for {names}")
|
||||||
|
return DeployStep("source", "ready", "-", "source trees are readable")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep:
|
||||||
|
missing = _missing_outputs(config)
|
||||||
|
if missing:
|
||||||
|
return DeployStep(
|
||||||
|
"build",
|
||||||
|
"todo",
|
||||||
|
commands.command("build"),
|
||||||
|
f"build {len(missing)} protocol output(s)",
|
||||||
|
)
|
||||||
|
return DeployStep("build", "ready", commands.command("build"), "selected outputs are built")
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_step(config: GatewayConfig) -> DeployStep:
|
||||||
|
clearnet_sites = [
|
||||||
|
site.domain
|
||||||
|
for site in config.sites
|
||||||
|
if "clearnet" in site.protocols and site.protocols["clearnet"].enabled
|
||||||
|
]
|
||||||
|
if not clearnet_sites:
|
||||||
|
return DeployStep("dns", "skipped", "-", "clearnet is not selected")
|
||||||
|
domains = ", ".join(clearnet_sites)
|
||||||
|
return DeployStep(
|
||||||
|
"dns",
|
||||||
|
"review",
|
||||||
|
f"set A/AAAA records for {domains} to this server",
|
||||||
|
"clearnet needs DNS records; provider automation is not implemented yet",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _doctor_step(
|
||||||
|
config: GatewayConfig,
|
||||||
|
commands: _CommandContext,
|
||||||
|
*,
|
||||||
|
platform_provider: PlatformProvider | None,
|
||||||
|
daemon_probe: DaemonProbeFunc | None,
|
||||||
|
) -> DeployStep:
|
||||||
|
issues = [
|
||||||
|
issue
|
||||||
|
for issue in doctor_gateway(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
)
|
||||||
|
if issue.code != "output-missing"
|
||||||
|
]
|
||||||
|
errors = [issue for issue in issues if issue.severity == "error"]
|
||||||
|
warnings = [issue for issue in issues if issue.severity == "warning"]
|
||||||
|
if errors:
|
||||||
|
return DeployStep(
|
||||||
|
"doctor",
|
||||||
|
"blocked",
|
||||||
|
commands.command("doctor", platform=True),
|
||||||
|
f"fix {len(errors)} configuration or daemon error(s)",
|
||||||
|
)
|
||||||
|
if warnings:
|
||||||
|
return DeployStep(
|
||||||
|
"doctor",
|
||||||
|
"review",
|
||||||
|
commands.command("doctor", platform=True),
|
||||||
|
f"review {len(warnings)} warning(s)",
|
||||||
|
)
|
||||||
|
return DeployStep("doctor", "ready", commands.command("doctor", platform=True), "checks pass")
|
||||||
|
|
||||||
|
|
||||||
|
def _daemon_step(
|
||||||
|
config: GatewayConfig,
|
||||||
|
commands: _CommandContext,
|
||||||
|
*,
|
||||||
|
platform_provider: PlatformProvider | None,
|
||||||
|
daemon_probe: DaemonProbeFunc | None,
|
||||||
|
) -> DeployStep:
|
||||||
|
steps = install_plan(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
)
|
||||||
|
blocked = [step for step in steps if step.status == "blocked"]
|
||||||
|
planned = [step for step in steps if step.status == "planned"]
|
||||||
|
if blocked:
|
||||||
|
return DeployStep(
|
||||||
|
"daemons",
|
||||||
|
"blocked",
|
||||||
|
commands.command("install-plan", platform=True),
|
||||||
|
f"fix {len(blocked)} daemon setup blocker(s)",
|
||||||
|
)
|
||||||
|
if planned:
|
||||||
|
return DeployStep(
|
||||||
|
"daemons",
|
||||||
|
"todo",
|
||||||
|
commands.command("install-plan --write-artifacts", platform=True),
|
||||||
|
f"review {len(planned)} daemon setup action(s)",
|
||||||
|
)
|
||||||
|
return DeployStep(
|
||||||
|
"daemons",
|
||||||
|
"ready",
|
||||||
|
commands.command("install-plan", platform=True),
|
||||||
|
"daemon setup is ready or adopted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_step(
|
||||||
|
config: GatewayConfig,
|
||||||
|
commands: _CommandContext,
|
||||||
|
*,
|
||||||
|
platform_provider: PlatformProvider | None,
|
||||||
|
) -> DeployStep:
|
||||||
|
approvals = load_approval_registry(config)
|
||||||
|
checks = [
|
||||||
|
approval_check(candidate, approvals)
|
||||||
|
for candidate in _approval_candidates(config, platform_provider)
|
||||||
|
]
|
||||||
|
if not checks:
|
||||||
|
return DeployStep("artifacts", "ready", "-", "no generated artifacts require approval")
|
||||||
|
missing = [check for check in checks if check.status in {"missing", "stale"}]
|
||||||
|
review = [check for check in checks if check.status == "review"]
|
||||||
|
if missing:
|
||||||
|
return DeployStep(
|
||||||
|
"artifacts",
|
||||||
|
"todo",
|
||||||
|
commands.command("apply --dry-run --write-artifacts", platform=True),
|
||||||
|
f"generate or refresh {len(missing)} review artifact(s)",
|
||||||
|
)
|
||||||
|
if review:
|
||||||
|
return DeployStep(
|
||||||
|
"artifacts",
|
||||||
|
"review",
|
||||||
|
commands.command("approvals approve --all", platform=True),
|
||||||
|
f"review and approve {len(review)} artifact digest(s)",
|
||||||
|
)
|
||||||
|
return DeployStep(
|
||||||
|
"artifacts",
|
||||||
|
"ready",
|
||||||
|
commands.command("approvals list", platform=True),
|
||||||
|
"artifact digests are approved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _address_step(config: GatewayConfig, commands: _CommandContext) -> DeployStep:
|
||||||
|
records = effective_address_records(config)
|
||||||
|
placeholders = [record for record in records if record.address_status == "placeholder"]
|
||||||
|
if placeholders:
|
||||||
|
names = ", ".join(f"{record.site_id}/{record.protocol}" for record in placeholders)
|
||||||
|
return DeployStep(
|
||||||
|
"addresses",
|
||||||
|
"review",
|
||||||
|
commands.command("addresses capture"),
|
||||||
|
f"capture or set generated transport address(es): {names}",
|
||||||
|
)
|
||||||
|
return DeployStep("addresses", "ready", commands.command("addresses list"), "addresses are known")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_preflight_step(
|
||||||
|
config: GatewayConfig,
|
||||||
|
commands: _CommandContext,
|
||||||
|
*,
|
||||||
|
platform_provider: PlatformProvider | None,
|
||||||
|
daemon_probe: DaemonProbeFunc | None,
|
||||||
|
) -> DeployStep:
|
||||||
|
activation = activation_steps(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
)
|
||||||
|
preflight = apply_preflight(
|
||||||
|
activation=activation,
|
||||||
|
state_copies=install_state_copies(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
),
|
||||||
|
supervisor_actions=install_supervisor_actions(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status = preflight.status
|
||||||
|
if status == "blocked" and _missing_outputs(config):
|
||||||
|
status = "todo"
|
||||||
|
return DeployStep(
|
||||||
|
"apply-preflight",
|
||||||
|
status,
|
||||||
|
commands.command("apply --dry-run", platform=True),
|
||||||
|
preflight.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_candidates(
|
||||||
|
config: GatewayConfig,
|
||||||
|
platform_provider: PlatformProvider | None,
|
||||||
|
) -> list[ApprovalInput]:
|
||||||
|
candidates: list[ApprovalInput] = []
|
||||||
|
for site in config.sites:
|
||||||
|
for protocol in site.protocols.values():
|
||||||
|
if not protocol.enabled:
|
||||||
|
continue
|
||||||
|
for artifact in plan_artifacts(site, protocol):
|
||||||
|
candidates.append(
|
||||||
|
ApprovalInput(
|
||||||
|
site_id=site.id,
|
||||||
|
protocol=protocol.name,
|
||||||
|
platform=GENERIC_PLATFORM,
|
||||||
|
kind=ACTIVATION_ARTIFACT_KIND,
|
||||||
|
path=artifact.path,
|
||||||
|
content=artifact.content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidates.extend(
|
||||||
|
ApprovalInput(
|
||||||
|
site_id=artifact.site_id,
|
||||||
|
protocol=artifact.protocol,
|
||||||
|
platform=artifact.platform,
|
||||||
|
kind=artifact.kind,
|
||||||
|
path=artifact.path,
|
||||||
|
content=artifact.content,
|
||||||
|
)
|
||||||
|
for artifact in install_artifacts(config, platform_provider=platform_provider)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_outputs(config: GatewayConfig) -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
for site in config.sites:
|
||||||
|
for protocol in site.protocols.values():
|
||||||
|
if not protocol.enabled:
|
||||||
|
continue
|
||||||
|
marker = site.outputs.root / protocol.name / ".ampg-output"
|
||||||
|
if not marker.exists():
|
||||||
|
missing.append(f"{site.id}/{protocol.name}")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def _next_steps(steps: tuple[DeployStep, ...]) -> tuple[DeployNextStep, ...]:
|
||||||
|
actionable = [
|
||||||
|
step
|
||||||
|
for step in steps
|
||||||
|
if step.status in {"todo", "review", "blocked"} and step.command != "-"
|
||||||
|
]
|
||||||
|
return tuple(
|
||||||
|
DeployNextStep(stage=step.stage, command=step.command, message=step.message)
|
||||||
|
for step in actionable[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _overall_status(steps: tuple[DeployStep, ...]) -> str:
|
||||||
|
statuses = [step.status for step in steps]
|
||||||
|
if "blocked" in statuses:
|
||||||
|
return "blocked"
|
||||||
|
if "todo" in statuses:
|
||||||
|
return "todo"
|
||||||
|
if "review" in statuses:
|
||||||
|
return "review"
|
||||||
|
return "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def _overall_message(status: str) -> str:
|
||||||
|
if status == "blocked":
|
||||||
|
return "deployment has blockers that must be fixed"
|
||||||
|
if status == "todo":
|
||||||
|
return "deployment has setup steps to run"
|
||||||
|
if status == "review":
|
||||||
|
return "deployment is waiting for operator review"
|
||||||
|
return "deployment plan is ready"
|
||||||
+1
-1
@@ -197,7 +197,7 @@ def _protocol_section(site_id: str, protocol: str, *, enabled: bool) -> list[str
|
|||||||
f"enabled = {_toml_bool(enabled)}",
|
f"enabled = {_toml_bool(enabled)}",
|
||||||
'renderer = "clearnet"',
|
'renderer = "clearnet"',
|
||||||
'daemon = "nginx"',
|
'daemon = "nginx"',
|
||||||
'daemon_policy = "adopt"',
|
'daemon_policy = "auto"',
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
if protocol == "tor":
|
if protocol == "tor":
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ python3 -m ampg --config gateway.toml doctor --platform android-termux
|
|||||||
This keeps the same gateway config portable across a VPS, laptop, old phone, or manual
|
This keeps the same gateway config portable across a VPS, laptop, old phone, or manual
|
||||||
render-only environment while making daemon ownership decisions explicit.
|
render-only environment while making daemon ownership decisions explicit.
|
||||||
|
|
||||||
|
## Deploy plan
|
||||||
|
|
||||||
|
`ampg deploy plan` is the operator-friendly layer over build, doctor, install-plan,
|
||||||
|
approvals, address capture, and apply dry-run. It emits stage rows with `ready`, `todo`,
|
||||||
|
`review`, `blocked`, or `skipped` status and short next-step commands. It is read-only.
|
||||||
|
|
||||||
## Activation plan
|
## Activation plan
|
||||||
|
|
||||||
`ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered
|
`ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ The generated config keeps common transports as `enabled = true/false` toggles a
|
|||||||
creates practical profiles such as `vps-full`, `tor-i2p`, and `mobile-i2p` when they
|
creates practical profiles such as `vps-full`, `tor-i2p`, and `mobile-i2p` when they
|
||||||
apply.
|
apply.
|
||||||
|
|
||||||
|
Then run `deploy plan` for a concise checklist:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m ampg --config gateway.toml deploy plan --profile vps-full
|
||||||
|
```
|
||||||
|
|
||||||
## Minimal Wownero pilot config
|
## Minimal Wownero pilot config
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ config artifacts.
|
|||||||
## Preflight commands
|
## Preflight commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
python3 -m ampg --config gateway.toml deploy plan --profile mobile-i2p
|
||||||
python3 -m ampg --config gateway.toml status
|
python3 -m ampg --config gateway.toml status
|
||||||
python3 -m ampg --config gateway.toml doctor
|
python3 -m ampg --config gateway.toml doctor
|
||||||
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
|
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
|
||||||
@@ -54,6 +55,10 @@ python3 -m ampg --config gateway.toml approvals approve --profile mobile-i2p --a
|
|||||||
python3 -m ampg --config gateway.toml doctor --platform android-termux
|
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.
|
||||||
|
|
||||||
`status` prints one row per enabled protocol with the selected platform provider,
|
`status` prints one row per enabled protocol with the selected platform provider,
|
||||||
adapter, installed/running daemon probe, action, and policy result.
|
adapter, installed/running daemon probe, action, and policy result.
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ daemon_policy = "auto"
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full
|
python3 -m ampg --config gateway.toml init site wownero --domain wownero.org --source ../wownero.org-website --preset full
|
||||||
|
python3 -m ampg --config gateway.toml deploy plan --profile vps-full
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml plan
|
python3 -m ampg --config examples/wownero.gateway.toml plan
|
||||||
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 manifest
|
python3 -m ampg --config examples/wownero.gateway.toml manifest
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
class DeployPlanTest(unittest.TestCase):
|
||||||
|
def test_deploy_plan_guides_fresh_i2p_only_site(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)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"plan",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, status, error)
|
||||||
|
self.assertIn("AMPG_DEPLOY_SUMMARY status=todo", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=source status=ready", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=build status=todo", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=dns status=skipped", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=artifacts status=todo", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_NEXT step=1 stage=build", output)
|
||||||
|
self.assertIn("build --profile mobile-i2p", output)
|
||||||
|
|
||||||
|
def test_deploy_plan_shows_dns_review_for_clearnet_site(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)),
|
||||||
|
"--preset",
|
||||||
|
"full",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.assertEqual(0, init_status, init_error)
|
||||||
|
|
||||||
|
_, output, _ = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"plan",
|
||||||
|
"--profile",
|
||||||
|
"vps-full",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=dns status=review", output)
|
||||||
|
self.assertIn("set A/AAAA records for example.test", output)
|
||||||
|
|
||||||
|
def test_deploy_plan_after_build_moves_build_stage_to_ready(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)
|
||||||
|
build_status, _, build_error = _run_cli(["--config", str(config_path), "build"])
|
||||||
|
self.assertEqual(0, build_status, build_error)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"plan",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, status, error)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=build status=ready", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STEP stage=artifacts status=todo", output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -87,6 +87,31 @@ class OnboardingTest(unittest.TestCase):
|
|||||||
self.assertFalse(config.sites[0].protocols["tor"].enabled)
|
self.assertFalse(config.sites[0].protocols["tor"].enabled)
|
||||||
self.assertIn("mobile-i2p", config.profiles)
|
self.assertIn("mobile-i2p", config.profiles)
|
||||||
|
|
||||||
|
def test_init_site_full_preset_uses_auto_clearnet_for_new_hosts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = root / "gateway.toml"
|
||||||
|
|
||||||
|
status, _, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"init",
|
||||||
|
"site",
|
||||||
|
"example",
|
||||||
|
"--domain",
|
||||||
|
"example.test",
|
||||||
|
"--source",
|
||||||
|
str(_source(root)),
|
||||||
|
"--preset",
|
||||||
|
"full",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
self.assertEqual(0, status, error)
|
||||||
|
self.assertEqual("auto", config.sites[0].protocols["clearnet"].daemon_policy)
|
||||||
|
|
||||||
def test_init_site_refuses_overwrite_without_force(self):
|
def test_init_site_refuses_overwrite_without_force(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp).resolve()
|
root = Path(tmp).resolve()
|
||||||
|
|||||||
Reference in New Issue
Block a user