mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add state stage deploy apply
This commit is contained in:
@@ -56,15 +56,18 @@ python3 -m ampg --config gateway.toml build
|
|||||||
python3 -m ampg --config gateway.toml doctor
|
python3 -m ampg --config gateway.toml doctor
|
||||||
python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts
|
python3 -m ampg --config gateway.toml install-plan --profile vps-full --write-artifacts
|
||||||
python3 -m ampg --config gateway.toml approvals list --profile vps-full
|
python3 -m ampg --config gateway.toml approvals list --profile vps-full
|
||||||
|
python3 -m ampg --config gateway.toml approvals approve --profile vps-full --all
|
||||||
python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full
|
python3 -m ampg --config gateway.toml apply --dry-run --profile vps-full
|
||||||
|
python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --profile vps-full
|
||||||
```
|
```
|
||||||
|
|
||||||
`init site` writes a readable `gateway.toml` with selected transports enabled and common
|
`init site` writes a readable `gateway.toml` with selected transports enabled and common
|
||||||
transports left as one-line toggles. `deploy plan` condenses the lower-level checks into
|
transports left as one-line toggles. `deploy plan` condenses the lower-level checks into
|
||||||
clear next steps. `dns plan` covers static DNS, Dynamic DNS, and behind-router
|
clear next steps. `dns plan` covers static DNS, Dynamic DNS, and behind-router
|
||||||
reachability hints. The current `apply --dry-run` prints the activation sequence and a
|
reachability hints. `apply --dry-run` prints the activation sequence and a preflight
|
||||||
preflight verdict without changing services. Live apply support will perform only
|
verdict without changing services. `deploy apply --stage state` copies only approved
|
||||||
approved changes.
|
managed-daemon artifacts into AMPG-owned state; later live stages will install and start
|
||||||
|
services after the same review gate.
|
||||||
|
|
||||||
## Interaction tiers
|
## Interaction tiers
|
||||||
|
|
||||||
@@ -126,6 +129,8 @@ python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --protoco
|
|||||||
python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --write-artifacts
|
python3 -m ampg --config examples/wownero.gateway.toml apply --dry-run --write-artifacts
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile mobile-i2p
|
python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile mobile-i2p
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml approvals approve --profile mobile-i2p --all
|
python3 -m ampg --config examples/wownero.gateway.toml approvals approve --profile mobile-i2p --all
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --dry-run --profile mobile-i2p
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --profile mobile-i2p --yes
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
python3 -m ampg --config examples/wownero.gateway.toml build
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p
|
python3 -m ampg --config examples/wownero.gateway.toml build --profile tor-i2p
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p
|
python3 -m ampg --config examples/wownero.gateway.toml build --protocol tor --protocol i2p
|
||||||
@@ -173,6 +178,11 @@ would be copied into managed state and the supervisor services that would be reg
|
|||||||
or started. The preflight gate reports `blocked`, `review`, or `ready` across activation
|
or started. The preflight gate reports `blocked`, `review`, or `ready` across activation
|
||||||
steps, managed-state copies, and supervisor actions.
|
steps, managed-state copies, and supervisor actions.
|
||||||
|
|
||||||
|
`deploy apply --stage state --dry-run` shows approved managed-daemon artifacts that are
|
||||||
|
ready to copy into `gateway.state_dir`. The live form requires `--yes`; it creates only
|
||||||
|
AMPG-owned state directories and copies approved artifact contents. It does not install
|
||||||
|
packages, change adopted daemons, start services, or remove keys.
|
||||||
|
|
||||||
Use `--protocol` to scope operational commands to one or more enabled protocols. This
|
Use `--protocol` to scope operational commands to one or more enabled protocols. This
|
||||||
lets a full site config build or activate only Tor, only I2P, or a selected subset without
|
lets a full site config build or activate only Tor, only I2P, or a selected subset without
|
||||||
unselected transports blocking the run.
|
unselected transports blocking the run.
|
||||||
@@ -193,4 +203,5 @@ Explicit `--protocol` and `--platform` flags override the profile when present.
|
|||||||
- [ ] Run `ampg health-plan`; review post-start transport checks.
|
- [ ] Run `ampg health-plan`; review post-start transport checks.
|
||||||
- [ ] Run `ampg approvals approve --all` after reviewing generated artifacts.
|
- [ ] Run `ampg approvals approve --all` after reviewing generated artifacts.
|
||||||
- [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate.
|
- [ ] Run `ampg apply --dry-run`; review the activation sequence and preflight gate.
|
||||||
|
- [ ] Run `ampg deploy apply --stage state --dry-run`; confirm approved state copies.
|
||||||
- [ ] Run live apply after the preflight gate is ready.
|
- [ ] Run live apply after the preflight gate is ready.
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from .config import GatewayConfig
|
||||||
|
from .install_plan import InstallStateCopy, install_state_copies
|
||||||
|
from .platforms import PlatformProvider
|
||||||
|
from .state_contract import gateway_state_root
|
||||||
|
from .status import DaemonProbeFunc
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateApplyResult:
|
||||||
|
site_id: str
|
||||||
|
protocol: str
|
||||||
|
platform: str
|
||||||
|
kind: str
|
||||||
|
source: Path
|
||||||
|
target: Path
|
||||||
|
status: str
|
||||||
|
mode: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def apply_state(
|
||||||
|
config: GatewayConfig,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
platform_provider: PlatformProvider | None = None,
|
||||||
|
daemon_probe: DaemonProbeFunc | None = None,
|
||||||
|
) -> list[StateApplyResult]:
|
||||||
|
copies = install_state_copies(
|
||||||
|
config,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
daemon_probe=daemon_probe,
|
||||||
|
)
|
||||||
|
results: list[StateApplyResult] = []
|
||||||
|
for copy in copies:
|
||||||
|
validation = _validate_state_copy(config, copy, dry_run=dry_run)
|
||||||
|
if validation.status == "blocked":
|
||||||
|
results.append(validation)
|
||||||
|
continue
|
||||||
|
if not dry_run:
|
||||||
|
copy.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(copy.source, copy.target)
|
||||||
|
results.append(
|
||||||
|
StateApplyResult(
|
||||||
|
site_id=copy.site_id,
|
||||||
|
protocol=copy.protocol,
|
||||||
|
platform=copy.platform,
|
||||||
|
kind=copy.kind,
|
||||||
|
source=copy.source,
|
||||||
|
target=copy.target,
|
||||||
|
status="planned" if dry_run else "written",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message=(
|
||||||
|
"would copy approved artifact into AMPG-owned state"
|
||||||
|
if dry_run
|
||||||
|
else "copied approved artifact into AMPG-owned state"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_state_copy(
|
||||||
|
config: GatewayConfig,
|
||||||
|
copy: InstallStateCopy,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> StateApplyResult:
|
||||||
|
if copy.status != "planned":
|
||||||
|
return _result(
|
||||||
|
copy,
|
||||||
|
status="blocked",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message=copy.message,
|
||||||
|
)
|
||||||
|
state_root = gateway_state_root(config).resolve()
|
||||||
|
target = copy.target.resolve()
|
||||||
|
if not _is_relative_to(target, state_root):
|
||||||
|
return _result(
|
||||||
|
copy,
|
||||||
|
status="blocked",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message=f"target is outside AMPG state root: {target}",
|
||||||
|
)
|
||||||
|
if not copy.source.exists():
|
||||||
|
return _result(
|
||||||
|
copy,
|
||||||
|
status="blocked",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message="approved source artifact is missing",
|
||||||
|
)
|
||||||
|
if copy.source.is_dir():
|
||||||
|
return _result(
|
||||||
|
copy,
|
||||||
|
status="blocked",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message="approved source artifact is a directory",
|
||||||
|
)
|
||||||
|
return _result(
|
||||||
|
copy,
|
||||||
|
status="ready",
|
||||||
|
mode="dry-run" if dry_run else "live",
|
||||||
|
message="state copy passed safety checks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
copy: InstallStateCopy,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
mode: str,
|
||||||
|
message: str,
|
||||||
|
) -> StateApplyResult:
|
||||||
|
return StateApplyResult(
|
||||||
|
site_id=copy.site_id,
|
||||||
|
protocol=copy.protocol,
|
||||||
|
platform=copy.platform,
|
||||||
|
kind=copy.kind,
|
||||||
|
source=copy.source,
|
||||||
|
target=copy.target,
|
||||||
|
status=status,
|
||||||
|
mode=mode,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_relative_to(path: Path, parent: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(parent)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
+71
@@ -21,6 +21,7 @@ from .addresses import (
|
|||||||
effective_address_records,
|
effective_address_records,
|
||||||
set_address,
|
set_address,
|
||||||
)
|
)
|
||||||
|
from .apply import StateApplyResult, apply_state
|
||||||
from .approvals import (
|
from .approvals import (
|
||||||
ACTIVATION_ARTIFACT_KIND,
|
ACTIVATION_ARTIFACT_KIND,
|
||||||
GENERIC_PLATFORM,
|
GENERIC_PLATFORM,
|
||||||
@@ -143,6 +144,32 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
choices=PLATFORM_NAMES,
|
choices=PLATFORM_NAMES,
|
||||||
help="Override platform detection for deploy planning.",
|
help="Override platform detection for deploy planning.",
|
||||||
)
|
)
|
||||||
|
deploy_apply_parser = deploy_subcommands.add_parser(
|
||||||
|
"apply",
|
||||||
|
help="Apply an approved deployment stage.",
|
||||||
|
)
|
||||||
|
_add_target_selection(deploy_apply_parser)
|
||||||
|
deploy_apply_parser.add_argument(
|
||||||
|
"--stage",
|
||||||
|
choices=("state",),
|
||||||
|
required=True,
|
||||||
|
help="Deployment stage to apply.",
|
||||||
|
)
|
||||||
|
deploy_apply_parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would change without writing files.",
|
||||||
|
)
|
||||||
|
deploy_apply_parser.add_argument(
|
||||||
|
"--yes",
|
||||||
|
action="store_true",
|
||||||
|
help="Required for live stage application.",
|
||||||
|
)
|
||||||
|
deploy_apply_parser.add_argument(
|
||||||
|
"--platform",
|
||||||
|
choices=PLATFORM_NAMES,
|
||||||
|
help="Override platform detection for deploy apply.",
|
||||||
|
)
|
||||||
dns_parser = subcommands.add_parser(
|
dns_parser = subcommands.add_parser(
|
||||||
"dns",
|
"dns",
|
||||||
help="Plan and check clearnet DNS records.",
|
help="Plan and check clearnet DNS records.",
|
||||||
@@ -530,6 +557,35 @@ def _cmd_deploy(config, args) -> int:
|
|||||||
_print_deploy_next(index, next_step)
|
_print_deploy_next(index, next_step)
|
||||||
_print_deploy_summary(config, plan)
|
_print_deploy_summary(config, plan)
|
||||||
return 1 if plan.status == "blocked" else 0
|
return 1 if plan.status == "blocked" else 0
|
||||||
|
if args.deploy_command == "apply":
|
||||||
|
if not args.dry_run and not args.yes:
|
||||||
|
print(
|
||||||
|
'AMPG_DEPLOY_APPLY status=error message="live deploy apply requires --yes or rerun with --dry-run"',
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
platform_provider = _platform_override(config, args)
|
||||||
|
if args.stage == "state":
|
||||||
|
results = apply_state(
|
||||||
|
config,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
platform_provider=platform_provider,
|
||||||
|
)
|
||||||
|
for result in results:
|
||||||
|
_print_state_apply_result(result)
|
||||||
|
blocked = [result for result in results if result.status == "blocked"]
|
||||||
|
written = [result for result in results if result.status == "written"]
|
||||||
|
planned = [result for result in results if result.status == "planned"]
|
||||||
|
print(
|
||||||
|
"AMPG_DEPLOY_APPLY_SUMMARY "
|
||||||
|
f"stage=state "
|
||||||
|
f"mode={'dry-run' if args.dry_run else 'live'} "
|
||||||
|
f"results={len(results)} "
|
||||||
|
f"planned={len(planned)} "
|
||||||
|
f"written={len(written)} "
|
||||||
|
f"blocked={len(blocked)}"
|
||||||
|
)
|
||||||
|
return 1 if blocked else 0
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
@@ -1000,6 +1056,21 @@ def _print_deploy_summary(config, plan: DeployPlan) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_state_apply_result(result: StateApplyResult) -> None:
|
||||||
|
print(
|
||||||
|
"AMPG_DEPLOY_STATE "
|
||||||
|
f"site={result.site_id} "
|
||||||
|
f"protocol={result.protocol} "
|
||||||
|
f"platform={result.platform} "
|
||||||
|
f"kind={result.kind} "
|
||||||
|
f"mode={result.mode} "
|
||||||
|
f"source=\"{_quote(str(result.source))}\" "
|
||||||
|
f"target=\"{_quote(str(result.target))}\" "
|
||||||
|
f"status={result.status} "
|
||||||
|
f"message=\"{_quote(result.message)}\""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _print_dns_record(record: DNSRecordPlan) -> None:
|
def _print_dns_record(record: DNSRecordPlan) -> None:
|
||||||
print(
|
print(
|
||||||
"AMPG_DNS_RECORD "
|
"AMPG_DNS_RECORD "
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ managed state and planned supervisor start/reload actions. The command emits an
|
|||||||
Blocked or unapproved preflight items stop mutation before any live apply path can touch
|
Blocked or unapproved preflight items stop mutation before any live apply path can touch
|
||||||
services.
|
services.
|
||||||
|
|
||||||
|
`ampg deploy apply --stage state` is the narrow live stage for managed state. It reads
|
||||||
|
the same approved artifact plan, refuses missing, stale, or unapproved inputs, and copies
|
||||||
|
only approved generated daemon config into AMPG-owned state. Service installation,
|
||||||
|
supervisor registration, daemon start, address capture, and health verification remain
|
||||||
|
separate stages.
|
||||||
|
|
||||||
The dry-run command is intentionally stricter than `doctor`: missing generated output is
|
The dry-run command is intentionally stricter than `doctor`: missing generated output is
|
||||||
blocked during activation, because AMPG must not point a transport at an unbuilt output
|
blocked during activation, because AMPG must not point a transport at an unbuilt output
|
||||||
root.
|
root.
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ python3 -m ampg --config gateway.toml apply --dry-run --protocol i2p
|
|||||||
python3 -m ampg --config gateway.toml apply --dry-run --write-artifacts
|
python3 -m ampg --config gateway.toml apply --dry-run --write-artifacts
|
||||||
python3 -m ampg --config gateway.toml approvals list --profile mobile-i2p
|
python3 -m ampg --config gateway.toml approvals list --profile mobile-i2p
|
||||||
python3 -m ampg --config gateway.toml approvals approve --profile mobile-i2p --all
|
python3 -m ampg --config gateway.toml approvals approve --profile mobile-i2p --all
|
||||||
|
python3 -m ampg --config gateway.toml deploy apply --stage state --dry-run --profile mobile-i2p
|
||||||
|
python3 -m ampg --config gateway.toml deploy apply --stage state --profile mobile-i2p --yes
|
||||||
python3 -m ampg --config gateway.toml doctor --platform android-termux
|
python3 -m ampg --config gateway.toml doctor --platform android-termux
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,6 +114,12 @@ reviewable config snippets to the configured plan root, then prints the copies f
|
|||||||
approved artifacts into `gateway.state_dir` and the supervisor services that would be
|
approved artifacts into `gateway.state_dir` and the supervisor services that would be
|
||||||
registered or started. It still does not install or reload services.
|
registered or started. It still does not install or reload services.
|
||||||
|
|
||||||
|
`deploy apply --stage state` is the first live apply stage. Dry-run mode prints
|
||||||
|
`AMPG_DEPLOY_STATE` rows. Live mode requires `--yes`, refuses unapproved or stale
|
||||||
|
artifacts, creates AMPG-owned state directories, and copies approved managed-daemon
|
||||||
|
config into `gateway.state_dir`. It does not install packages, edit adopted daemon
|
||||||
|
configs, start services, or delete identity material.
|
||||||
|
|
||||||
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
|
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
|
||||||
`adopt` failure will block a full activation run, but it will not block
|
`adopt` failure will block a full activation run, but it will not block
|
||||||
`apply --dry-run --protocol i2p` because clearnet is outside that selected run.
|
`apply --dry-run --protocol i2p` because clearnet is outside that selected run.
|
||||||
|
|||||||
@@ -46,5 +46,9 @@ AMPG must never delete sensitive contract paths without an explicit destructive
|
|||||||
approved artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows
|
approved artifacts would be copied into managed state and `AMPG_APPLY_SUPERVISOR` rows
|
||||||
for the services that would be registered or started. The accompanying
|
for the services that would be registered or started. The accompanying
|
||||||
`AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when
|
`AMPG_APPLY_PREFLIGHT` verdict includes those rows so live apply can refuse mutation when
|
||||||
state or supervisor inputs still need review. Live apply should perform approved copies,
|
state or supervisor inputs still need review.
|
||||||
start daemons, capture public addresses, and then run published health checks.
|
|
||||||
|
`deploy apply --stage state` performs the approved copy portion only. It requires
|
||||||
|
`--yes` for live writes, refuses unapproved or stale artifacts, writes only under
|
||||||
|
`gateway.state_dir`, and leaves daemon start, address capture, and published health
|
||||||
|
checks for later stages.
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ daemon_policy = "auto"
|
|||||||
- `ampg build` writes a fixture manifest that AMPB can check.
|
- `ampg build` writes a fixture manifest that AMPB can check.
|
||||||
- `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live
|
- `ampg preview manifest` writes loopback fixture URLs that AMPB can check before live
|
||||||
transport daemons are installed or adopted.
|
transport daemons are installed or adopted.
|
||||||
|
- `ampg deploy apply --stage state --dry-run` shows approved managed-daemon config copies
|
||||||
|
before any Wownero transport service is started.
|
||||||
- Tor output contains no `<script>` tags or inline event handlers.
|
- Tor output contains no `<script>` tags or inline event handlers.
|
||||||
- Generated Gemini output has readable headings, paragraphs, and links.
|
- Generated Gemini output has readable headings, paragraphs, and links.
|
||||||
- Generated Micron output fits terminal-first browsing.
|
- Generated Micron output fits terminal-first browsing.
|
||||||
@@ -158,6 +160,10 @@ python3 -m ampg --config examples/wownero.gateway.toml manifest
|
|||||||
python3 -m ampg --config examples/wownero.gateway.toml addresses list
|
python3 -m ampg --config examples/wownero.gateway.toml addresses list
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints
|
python3 -m ampg --config examples/wownero.gateway.toml preview endpoints
|
||||||
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
python3 -m ampg --config examples/wownero.gateway.toml preview manifest
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml install-plan --profile mobile-i2p --write-artifacts
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml approvals list --profile mobile-i2p
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml approvals approve --profile mobile-i2p --all
|
||||||
|
python3 -m ampg --config examples/wownero.gateway.toml deploy apply --stage state --dry-run --profile mobile-i2p
|
||||||
python3 -m ampg --config examples/i2p-only.gateway.toml plan
|
python3 -m ampg --config examples/i2p-only.gateway.toml plan
|
||||||
python3 -m ampg --config examples/i2p-only.gateway.toml build
|
python3 -m ampg --config examples/i2p-only.gateway.toml build
|
||||||
```
|
```
|
||||||
@@ -170,3 +176,4 @@ python3 -m ampg --config examples/i2p-only.gateway.toml build
|
|||||||
- [ ] Add Gemini conversion fixtures.
|
- [ ] Add Gemini conversion fixtures.
|
||||||
- [ ] Add Reticulum/Micron conversion fixtures.
|
- [ ] Add Reticulum/Micron conversion fixtures.
|
||||||
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable.
|
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable.
|
||||||
|
- [ ] Copy only reviewed Wownero daemon artifacts into managed state.
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ampg.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
def _source(root: Path) -> Path:
|
||||||
|
source = root / "site"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "index.html").write_text("<h1>Hello</h1>", encoding="utf-8")
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str]) -> tuple[int, str, str]:
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||||
|
status = main(args)
|
||||||
|
return status, stdout.getvalue(), stderr.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_i2p_config(root: Path) -> Path:
|
||||||
|
config_path = root / "gateway.toml"
|
||||||
|
status, _, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"init",
|
||||||
|
"site",
|
||||||
|
"example",
|
||||||
|
"--domain",
|
||||||
|
"example.test",
|
||||||
|
"--source",
|
||||||
|
str(_source(root)),
|
||||||
|
"--protocol",
|
||||||
|
"i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if status != 0:
|
||||||
|
raise AssertionError(error)
|
||||||
|
output = root / "dist/example/i2p"
|
||||||
|
output.mkdir(parents=True)
|
||||||
|
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_and_approve_artifacts(config_path: Path) -> None:
|
||||||
|
status, _, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"apply",
|
||||||
|
"--dry-run",
|
||||||
|
"--write-artifacts",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if status != 0:
|
||||||
|
raise AssertionError(error)
|
||||||
|
status, _, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"approvals",
|
||||||
|
"approve",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
"--all",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if status != 0:
|
||||||
|
raise AssertionError(error)
|
||||||
|
|
||||||
|
|
||||||
|
class DeployApplyTest(unittest.TestCase):
|
||||||
|
def test_deploy_apply_state_blocks_unapproved_artifacts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = _init_i2p_config(root)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"apply",
|
||||||
|
"--stage",
|
||||||
|
"state",
|
||||||
|
"--dry-run",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, status)
|
||||||
|
self.assertEqual("", error)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STATE", output)
|
||||||
|
self.assertIn("status=blocked", output)
|
||||||
|
self.assertIn("artifact is missing", output)
|
||||||
|
|
||||||
|
def test_deploy_apply_state_dry_run_after_approval(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = _init_i2p_config(root)
|
||||||
|
_write_and_approve_artifacts(config_path)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"apply",
|
||||||
|
"--stage",
|
||||||
|
"state",
|
||||||
|
"--dry-run",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, status, error)
|
||||||
|
self.assertIn("AMPG_DEPLOY_STATE", output)
|
||||||
|
self.assertIn("status=planned", output)
|
||||||
|
self.assertIn("AMPG_DEPLOY_APPLY_SUMMARY stage=state mode=dry-run", output)
|
||||||
|
self.assertIn("blocked=0", output)
|
||||||
|
|
||||||
|
def test_deploy_apply_state_live_requires_yes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = _init_i2p_config(root)
|
||||||
|
_write_and_approve_artifacts(config_path)
|
||||||
|
|
||||||
|
status, _, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"apply",
|
||||||
|
"--stage",
|
||||||
|
"state",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, status)
|
||||||
|
self.assertIn("requires --yes", error)
|
||||||
|
|
||||||
|
def test_deploy_apply_state_blocks_stale_approved_artifacts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = _init_i2p_config(root)
|
||||||
|
_write_and_approve_artifacts(config_path)
|
||||||
|
artifact = (
|
||||||
|
root / "dist/ampg-plan/example/i2p/managed/android-termux/i2pd-tunnels.conf"
|
||||||
|
)
|
||||||
|
artifact.write_text(
|
||||||
|
artifact.read_text(encoding="utf-8") + "# edited\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"apply",
|
||||||
|
"--stage",
|
||||||
|
"state",
|
||||||
|
"--dry-run",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, status)
|
||||||
|
self.assertEqual("", error)
|
||||||
|
self.assertIn("status=blocked", output)
|
||||||
|
self.assertIn("artifact differs from current generator output", output)
|
||||||
|
|
||||||
|
def test_deploy_apply_state_live_copies_approved_artifacts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp).resolve()
|
||||||
|
config_path = _init_i2p_config(root)
|
||||||
|
_write_and_approve_artifacts(config_path)
|
||||||
|
|
||||||
|
status, output, error = _run_cli(
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"deploy",
|
||||||
|
"apply",
|
||||||
|
"--stage",
|
||||||
|
"state",
|
||||||
|
"--profile",
|
||||||
|
"mobile-i2p",
|
||||||
|
"--yes",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon_config = root / ".ampg/state/example/i2p/i2pd-tunnels.conf"
|
||||||
|
loopback_config = root / ".ampg/state/example/i2p/nginx-loopback.conf"
|
||||||
|
daemon_config_exists = daemon_config.exists()
|
||||||
|
loopback_config_exists = loopback_config.exists()
|
||||||
|
|
||||||
|
self.assertEqual(0, status, error)
|
||||||
|
self.assertIn("status=written", output)
|
||||||
|
self.assertTrue(daemon_config_exists)
|
||||||
|
self.assertTrue(loopback_config_exists)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user