diff --git a/README.md b/README.md index 50bf056..e57e117 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ python3 -m ampg --config gateway.toml init site wownero \ --source ../wownero.org-website \ --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 doctor 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 -transports left as one-line toggles. The current `apply --dry-run` prints the activation -sequence and a preflight verdict without changing services. Live apply support will -perform only approved changes. +transports left as one-line toggles. `deploy plan` condenses the lower-level checks into +clear next steps. The current `apply --dry-run` prints the activation sequence and a +preflight verdict without changing services. Live apply support will perform only +approved changes. ## Interaction tiers @@ -104,6 +106,7 @@ The current implementation is dependency-free Python: ```sh 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 --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml status diff --git a/ampg/cli.py b/ampg/cli.py index 0d4b156..e15fb5b 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -35,6 +35,7 @@ from .approvals import ( from .audit import audit_gateway from .build import build_gateway from .config import load_config +from .deploy import DeployPlan, DeployStep, DeployNextStep, deploy_plan from .docsgen import generate_docs from .health import HealthCheck, blocked_health_checks, health_plan from .install_plan import ( @@ -118,6 +119,21 @@ def main(argv: list[str] | None = None) -> int: action="store_true", 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.") _add_target_selection(plan_parser) plan_parser.add_argument( @@ -375,6 +391,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_status(config, args) if args.command == "doctor": return _cmd_doctor(config, args) + if args.command == "deploy": + return _cmd_deploy(config, args) if args.command == "apply": return _cmd_apply(config, args) if args.command == "approvals": @@ -451,6 +469,25 @@ def _cmd_init(args) -> int: 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: if not args.dry_run: 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: candidate = check.candidate print( diff --git a/ampg/deploy.py b/ampg/deploy.py new file mode 100644 index 0000000..ee1da81 --- /dev/null +++ b/ampg/deploy.py @@ -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" diff --git a/ampg/onboarding.py b/ampg/onboarding.py index eb37883..c3bdb69 100644 --- a/ampg/onboarding.py +++ b/ampg/onboarding.py @@ -197,7 +197,7 @@ def _protocol_section(site_id: str, protocol: str, *, enabled: bool) -> list[str f"enabled = {_toml_bool(enabled)}", 'renderer = "clearnet"', 'daemon = "nginx"', - 'daemon_policy = "adopt"', + 'daemon_policy = "auto"', "", ] if protocol == "tor": diff --git a/docs/architecture.md b/docs/architecture.md index f4f6521..d4a4bf9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 `ampg apply --dry-run` turns daemon status and generated config artifacts into an ordered diff --git a/docs/configuration.md b/docs/configuration.md index f248b39..2819b97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 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 ```toml diff --git a/docs/daemon-management.md b/docs/daemon-management.md index 8cc5a9b..7e77f35 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -37,6 +37,7 @@ config artifacts. ## Preflight commands ```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 doctor 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 ``` +`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, adapter, installed/running daemon probe, action, and policy result. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index 16562a6..c408eb7 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -150,6 +150,7 @@ daemon_policy = "auto" ```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 deploy plan --profile vps-full 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 manifest diff --git a/tests/test_deploy_plan.py b/tests/test_deploy_plan.py new file mode 100644 index 0000000..8999e50 --- /dev/null +++ b/tests/test_deploy_plan.py @@ -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("