Add address capture to activation plan

This commit is contained in:
jwinterm
2026-07-06 10:30:42 -04:00
parent bf2a171ec6
commit 12983ca134
9 changed files with 169 additions and 40 deletions
+5 -1
View File
@@ -140,12 +140,16 @@ passed. They are reviewable snippets, not installed daemon config.
Managed install artifacts are also written under `dist/ampg-plan/` when Managed install artifacts are also written under `dist/ampg-plan/` when
`install-plan --write-artifacts` is passed. They include reviewable daemon configs and `install-plan --write-artifacts` is passed. They include reviewable daemon configs and
platform supervisor files, but are not installed or started. platform supervisor files, but are not installed or started. Managed daemon configs point
runtime state at `gateway.state_dir`.
Captured transport addresses are written under `gateway.state_dir` by Captured transport addresses are written under `gateway.state_dir` by
`addresses capture` or `addresses set`. Fixture manifests and health plans use explicit `addresses capture` or `addresses set`. Fixture manifests and health plans use explicit
config first, then captured addresses, then deterministic placeholders. config first, then captured addresses, then deterministic placeholders.
`apply --dry-run` includes an address stage. Placeholder addresses remain review items
until the generated transport identity is captured or configured.
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.
+80 -8
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import shlex
from .addresses import AddressRecord, address_registry_path, effective_address_records
from .config import GatewayConfig, ProtocolConfig, SiteConfig from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .plan import plan_artifacts, write_plan_artifacts from .plan import plan_artifacts, write_plan_artifacts
from .platforms import PlatformProvider from .platforms import PlatformProvider
@@ -23,6 +25,7 @@ class ActivationStep:
target: str target: str
status: str status: str
message: str message: str
command: str = "-"
def activation_steps( def activation_steps(
@@ -39,6 +42,10 @@ def activation_steps(
status_by_protocol = { status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses (status.site_id, status.protocol): status for status in statuses
} }
address_by_protocol = {
(record.site_id, record.protocol): record
for record in effective_address_records(config)
}
steps: list[ActivationStep] = [] steps: list[ActivationStep] = []
for issue in doctor_gateway( for issue in doctor_gateway(
@@ -69,9 +76,11 @@ def activation_steps(
transport_status = status_by_protocol[(site.id, protocol.name)] transport_status = status_by_protocol[(site.id, protocol.name)]
steps.extend( steps.extend(
_protocol_steps( _protocol_steps(
config,
site, site,
protocol, protocol,
transport_status=transport_status, transport_status=transport_status,
address_record=address_by_protocol[(site.id, protocol.name)],
output_ready=output_ready, output_ready=output_ready,
) )
) )
@@ -84,10 +93,12 @@ def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]:
def _protocol_steps( def _protocol_steps(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
*, *,
transport_status: TransportStatus, transport_status: TransportStatus,
address_record: AddressRecord,
output_ready: bool, output_ready: bool,
) -> list[ActivationStep]: ) -> list[ActivationStep]:
output_root = site.outputs.root / protocol.name output_root = site.outputs.root / protocol.name
@@ -145,6 +156,7 @@ def _protocol_steps(
message=transport_status.message, message=transport_status.message,
) )
) )
steps.append(_address_step(config, site, protocol, address_record))
steps.append( steps.append(
ActivationStep( ActivationStep(
site_id=site.id, site_id=site.id,
@@ -152,21 +164,81 @@ def _protocol_steps(
stage="health", stage="health",
action="check-transport", action="check-transport",
target=protocol.name, target=protocol.name,
status=( status=_health_activation_status(
"planned" output_ready=output_ready,
if output_ready and transport_status.status != "error" transport_status=transport_status,
else "blocked" address_record=address_record,
), ),
message=( message=_health_activation_message(
"verify generated site through this transport after apply" output_ready=output_ready,
if output_ready and transport_status.status != "error" transport_status=transport_status,
else "health check is blocked until output and daemon steps are ready" address_record=address_record,
), ),
) )
) )
return steps return steps
def _address_step(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
address_record: AddressRecord,
) -> ActivationStep:
if address_record.address_status == "placeholder":
return ActivationStep(
site_id=site.id,
protocol=protocol.name,
stage="address",
action="capture-address",
target=str(address_registry_path(config)),
status="review",
message="generated transport address is not known yet",
command=_address_capture_command(config, protocol),
)
return ActivationStep(
site_id=site.id,
protocol=protocol.name,
stage="address",
action="use-address",
target=address_record.url,
status="ready",
message=f"using {address_record.address_status} transport address",
command="-",
)
def _address_capture_command(config: GatewayConfig, protocol: ProtocolConfig) -> str:
config_path = shlex.quote(str(config.config_path))
return f"python3 -m ampg --config {config_path} addresses capture --protocol {protocol.name}"
def _health_activation_status(
*,
output_ready: bool,
transport_status: TransportStatus,
address_record: AddressRecord,
) -> str:
if not output_ready or transport_status.status == "error":
return "blocked"
if address_record.address_status == "placeholder":
return "review"
return "planned"
def _health_activation_message(
*,
output_ready: bool,
transport_status: TransportStatus,
address_record: AddressRecord,
) -> str:
if not output_ready or transport_status.status == "error":
return "health check is blocked until output and daemon steps are ready"
if address_record.address_status == "placeholder":
return "capture or configure the generated transport address before published checks"
return "verify generated site through this transport after apply"
def _output_ready(site: SiteConfig, protocol: ProtocolConfig) -> bool: def _output_ready(site: SiteConfig, protocol: ProtocolConfig) -> bool:
return ((site.outputs.root / protocol.name) / ".ampg-output").exists() return ((site.outputs.root / protocol.name) / ".ampg-output").exists()
+1
View File
@@ -524,6 +524,7 @@ def _print_activation_step(step: ActivationStep) -> None:
f"action={step.action} " f"action={step.action} "
f"target={step.target} " f"target={step.target} "
f"status={step.status} " f"status={step.status} "
f"command=\"{_quote(step.command)}\" "
f"message=\"{_quote(step.message)}\"" f"message=\"{_quote(step.message)}\""
) )
+30 -18
View File
@@ -53,7 +53,7 @@ def install_plan(
if not protocol.enabled: if not protocol.enabled:
continue continue
status = status_by_protocol[(site.id, protocol.name)] status = status_by_protocol[(site.id, protocol.name)]
steps.extend(_protocol_install_steps(site, protocol, provider, status)) steps.extend(_protocol_install_steps(config, site, protocol, provider, status))
return steps return steps
@@ -82,7 +82,7 @@ def install_artifacts(
if not protocol.enabled: if not protocol.enabled:
continue continue
status = status_by_protocol[(site.id, protocol.name)] status = status_by_protocol[(site.id, protocol.name)]
artifacts.extend(_protocol_install_artifacts(site, protocol, provider, status)) artifacts.extend(_protocol_install_artifacts(config, site, protocol, provider, status))
return artifacts return artifacts
@@ -104,6 +104,7 @@ def write_install_artifacts(
def _protocol_install_steps( def _protocol_install_steps(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
@@ -170,9 +171,9 @@ def _protocol_install_steps(
provider, provider,
stage="state", stage="state",
action="create-state", action="create-state",
target=str(_state_dir(provider, site, protocol)), target=str(_state_dir(config, provider, site, protocol)),
status="planned", status="planned",
command=_mkdir_command(provider, _state_dir(provider, site, protocol)), command=_mkdir_command(provider, _state_dir(config, provider, site, protocol)),
message="create AMPG-owned state directory for daemon config, keys, and logs", message="create AMPG-owned state directory for daemon config, keys, and logs",
), ),
_step( _step(
@@ -181,7 +182,7 @@ def _protocol_install_steps(
provider, provider,
stage="config", stage="config",
action="write-managed-config", action="write-managed-config",
target=str(_state_dir(provider, site, protocol) / "config"), target=str(_state_dir(config, provider, site, protocol) / "config"),
status="planned", status="planned",
command="-", command="-",
message=f"write managed {adapter.backend} config after plan review", message=f"write managed {adapter.backend} config after plan review",
@@ -212,6 +213,7 @@ def _protocol_install_steps(
def _protocol_install_artifacts( def _protocol_install_artifacts(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
@@ -227,7 +229,7 @@ def _protocol_install_artifacts(
provider, provider,
kind="daemon-config", kind="daemon-config",
filename=_daemon_config_filename(protocol), filename=_daemon_config_filename(protocol),
content=_daemon_config_content(site, protocol, provider), content=_daemon_config_content(config, site, protocol, provider),
), ),
_artifact( _artifact(
site, site,
@@ -236,11 +238,12 @@ def _protocol_install_artifacts(
kind="daemon-supervisor", kind="daemon-supervisor",
filename=_supervisor_filename(provider, protocol.daemon), filename=_supervisor_filename(provider, protocol.daemon),
content=_supervisor_content( content=_supervisor_content(
config,
site, site,
protocol, protocol,
provider, provider,
service_suffix=protocol.daemon, service_suffix=protocol.daemon,
command=_daemon_start_command(site, protocol, provider), command=_daemon_start_command(config, site, protocol, provider),
), ),
), ),
] ]
@@ -253,7 +256,7 @@ def _protocol_install_artifacts(
provider, provider,
kind="loopback-config", kind="loopback-config",
filename="nginx-loopback.conf", filename="nginx-loopback.conf",
content=_managed_nginx_loopback_content(site, protocol, provider), content=_managed_nginx_loopback_content(config, site, protocol, provider),
), ),
_artifact( _artifact(
site, site,
@@ -262,11 +265,12 @@ def _protocol_install_artifacts(
kind="loopback-supervisor", kind="loopback-supervisor",
filename=_supervisor_filename(provider, "nginx-loopback"), filename=_supervisor_filename(provider, "nginx-loopback"),
content=_supervisor_content( content=_supervisor_content(
config,
site, site,
protocol, protocol,
provider, provider,
service_suffix="nginx-loopback", service_suffix="nginx-loopback",
command=_loopback_start_command(site, protocol, provider), command=_loopback_start_command(config, site, protocol, provider),
), ),
), ),
) )
@@ -347,11 +351,13 @@ def _supervisor_command(
def _state_dir( def _state_dir(
config: GatewayConfig,
provider: PlatformProvider, provider: PlatformProvider,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
) -> Path: ) -> Path:
return provider.state_root / site.id / protocol.name root = config.paths.state_dir if config.paths else provider.state_root
return root / site.id / protocol.name
def _artifact_dir( def _artifact_dir(
@@ -387,11 +393,12 @@ def _daemon_config_filename(protocol: ProtocolConfig) -> str:
def _daemon_config_content( def _daemon_config_content(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
) -> str: ) -> str:
state_dir = _state_dir(provider, site, protocol) state_dir = _state_dir(config, provider, site, protocol)
if protocol.name == "tor": if protocol.name == "tor":
port = int(protocol.options.get("local_port", 18080)) port = int(protocol.options.get("local_port", 18080))
hidden_service_dir = state_dir / "hidden-service" hidden_service_dir = state_dir / "hidden-service"
@@ -423,7 +430,7 @@ certificate = {state_dir / f"{site.id}.crt"}
private_key = {state_dir / f"{site.id}.key"} private_key = {state_dir / f"{site.id}.key"}
""" """
if protocol.name == "clearnet": if protocol.name == "clearnet":
return _managed_nginx_server_content(site, protocol, provider) return _managed_nginx_server_content(config, site, protocol, provider)
return f"""# Generated by AMPG. Review before installing. return f"""# Generated by AMPG. Review before installing.
daemon = {protocol.daemon} daemon = {protocol.daemon}
protocol = {protocol.name} protocol = {protocol.name}
@@ -433,11 +440,12 @@ state_dir = {state_dir}
def _managed_nginx_server_content( def _managed_nginx_server_content(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
) -> str: ) -> str:
state_dir = _state_dir(provider, site, protocol) state_dir = _state_dir(config, provider, site, protocol)
return f"""# Generated by AMPG. Review before installing. return f"""# Generated by AMPG. Review before installing.
worker_processes 1; worker_processes 1;
error_log {state_dir / "nginx-error.log"} notice; error_log {state_dir / "nginx-error.log"} notice;
@@ -465,11 +473,12 @@ http {{
def _managed_nginx_loopback_content( def _managed_nginx_loopback_content(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
) -> str: ) -> str:
state_dir = _state_dir(provider, site, protocol) state_dir = _state_dir(config, provider, site, protocol)
port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081)) port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081))
return f"""# Generated by AMPG. Review before installing. return f"""# Generated by AMPG. Review before installing.
worker_processes 1; worker_processes 1;
@@ -505,6 +514,7 @@ def _supervisor_filename(provider: PlatformProvider, service_suffix: str) -> str
def _supervisor_content( def _supervisor_content(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
@@ -522,7 +532,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory={_state_dir(provider, site, protocol)} WorkingDirectory={_state_dir(config, provider, site, protocol)}
ExecStart=/bin/sh -lc '{_shell_single_quote(command)}' ExecStart=/bin/sh -lc '{_shell_single_quote(command)}'
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
@@ -555,17 +565,18 @@ WantedBy=multi-user.target
return f"""#!{shell} return f"""#!{shell}
# Generated by AMPG. Review before installing. # Generated by AMPG. Review before installing.
set -eu set -eu
mkdir -p {_state_dir(provider, site, protocol)} mkdir -p {_state_dir(config, provider, site, protocol)}
{command} {command}
""" """
def _daemon_start_command( def _daemon_start_command(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
) -> str: ) -> str:
state_dir = _state_dir(provider, site, protocol) state_dir = _state_dir(config, provider, site, protocol)
if protocol.name == "tor": if protocol.name == "tor":
return f"exec tor -f {state_dir / 'torrc'}" return f"exec tor -f {state_dir / 'torrc'}"
if protocol.name == "i2p": if protocol.name == "i2p":
@@ -585,11 +596,12 @@ def _daemon_start_command(
def _loopback_start_command( def _loopback_start_command(
config: GatewayConfig,
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
provider: PlatformProvider, provider: PlatformProvider,
) -> str: ) -> str:
state_dir = _state_dir(provider, site, protocol) state_dir = _state_dir(config, provider, site, protocol)
return f"exec nginx -c {state_dir / 'nginx-loopback.conf'} -g 'daemon off;'" return f"exec nginx -c {state_dir / 'nginx-loopback.conf'} -g 'daemon off;'"
+3 -2
View File
@@ -111,7 +111,8 @@ render-only environment while making daemon ownership decisions explicit.
`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
activation sequence. Each enabled protocol gets output, artifact, daemon, and health activation sequence. Each enabled protocol gets output, artifact, daemon, and health
steps. Blocked steps stop the command before any live apply path can touch services. steps, plus an address step when AMPG must capture or confirm a generated transport
identity. Blocked steps stop the command before any live apply path can touch services.
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
@@ -126,7 +127,7 @@ future live apply support.
With `--write-artifacts`, install-plan writes managed config and supervisor files under With `--write-artifacts`, install-plan writes managed config and supervisor files under
the configured `plan_root`; those files are review artifacts, not installed service the configured `plan_root`; those files are review artifacts, not installed service
state. state. Generated managed-daemon configs point runtime state at `gateway.state_dir`.
Tor and I2P HTTP publishing include the transport daemon and the loopback web-serving Tor and I2P HTTP publishing include the transport daemon and the loopback web-serving
layer in the install plan, because the hidden service or tunnel must have a local HTTP layer in the install plan, because the hidden service or tunnel must have a local HTTP
+2 -2
View File
@@ -112,8 +112,8 @@ workflow.
`[gateway]` paths are resolved relative to `gateway.toml` when they are not absolute. `[gateway]` paths are resolved relative to `gateway.toml` when they are not absolute.
`state_dir` `state_dir`
: AMPG-owned durable state. Managed daemon identities, generated transport state, and : AMPG-owned durable state. Managed daemon identities, generated transport state, daemon
the captured address registry live here. logs, daemon-written addresses, and the captured address registry live here.
`cache_dir` `cache_dir`
: Rebuildable intermediate data. : Rebuildable intermediate data.
+6 -5
View File
@@ -66,7 +66,8 @@ blocked.
`install-plan --write-artifacts` writes managed daemon configs and platform supervisor `install-plan --write-artifacts` writes managed daemon configs and platform supervisor
files under the configured `plan_root`, grouped by site, protocol, and platform. For files under the configured `plan_root`, grouped by site, protocol, and platform. For
Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopback nginx
artifacts. artifacts. The generated daemon configs store runtime state, identity keys, logs, and
daemon-written addresses under `gateway.state_dir`.
`addresses list` prints the effective public address for each enabled protocol: `addresses list` prints the effective public address for each enabled protocol:
configured, captured, derived, or placeholder. `addresses capture` reads daemon-written configured, captured, derived, or placeholder. `addresses capture` reads daemon-written
@@ -78,10 +79,10 @@ target real transport URLs; preview checks target local loopback preview endpoin
Missing output blocks the plan, while placeholder transport addresses are review items. Missing output blocks the plan, while placeholder transport addresses are review items.
`apply --dry-run` prints the activation sequence for each enabled protocol: generated `apply --dry-run` prints the activation sequence for each enabled protocol: generated
output readiness, config artifacts to review, daemon action, and post-apply health output readiness, config artifacts to review, daemon action, address capture or reuse,
checks. It exits nonzero when any step is blocked. `--write-artifacts` writes reviewable and post-apply health checks. It exits nonzero when any step is blocked. `--write-artifacts`
config snippets to the configured plan root but still does not install or reload writes reviewable config snippets to the configured plan root but still does not install
services. or reload services.
`--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
+36 -2
View File
@@ -50,7 +50,7 @@ plan_root = "./plan"
class ActivationTest(unittest.TestCase): class ActivationTest(unittest.TestCase):
def test_activation_plan_is_ready_when_output_and_daemon_policy_are_ready(self): def test_activation_plan_reviews_placeholder_address_before_health(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
config_path = _write_config( config_path = _write_config(
@@ -79,9 +79,42 @@ local_port = 19081
by_stage = {step.stage: step for step in steps if step.stage != "artifact"} by_stage = {step.stage: step for step in steps if step.stage != "artifact"}
self.assertEqual("ready", by_stage["output"].status) self.assertEqual("ready", by_stage["output"].status)
self.assertEqual("manage-owned", by_stage["daemon"].action) self.assertEqual("manage-owned", by_stage["daemon"].action)
self.assertEqual("planned", by_stage["health"].status) self.assertEqual("capture-address", by_stage["address"].action)
self.assertEqual("review", by_stage["address"].status)
self.assertIn("addresses capture --protocol i2p", by_stage["address"].command)
self.assertEqual("review", by_stage["health"].status)
self.assertTrue(any(step.stage == "artifact" for step in steps)) self.assertTrue(any(step.stage == "artifact" for step in steps))
def test_configured_address_allows_planned_activation_health(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_path = _write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
daemon = "i2pd"
daemon_policy = "auto"
i2p_url = "ready.b32.i2p"
""",
)
output = root / "out/i2p"
output.mkdir(parents=True)
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
config = load_config(config_path)
steps = activation_steps(
config,
platform_provider=platform_by_name("android-termux"),
daemon_probe=_probe(installed=False, running=False),
)
by_stage = {step.stage: step for step in steps if step.stage != "artifact"}
self.assertEqual("use-address", by_stage["address"].action)
self.assertEqual("ready", by_stage["address"].status)
self.assertEqual("planned", by_stage["health"].status)
def test_missing_output_blocks_activation(self): def test_missing_output_blocks_activation(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
config_path = _write_config( config_path = _write_config(
@@ -162,6 +195,7 @@ port = 1965
output_text = stdout.getvalue() output_text = stdout.getvalue()
self.assertIn("AMPG_APPLY_ARTIFACT", output_text) self.assertIn("AMPG_APPLY_ARTIFACT", output_text)
self.assertIn("AMPG_APPLY_STEP", output_text) self.assertIn("AMPG_APPLY_STEP", output_text)
self.assertIn('command="-"', output_text)
self.assertIn("blocked=0", output_text) self.assertIn("blocked=0", output_text)
artifact_line = next( artifact_line = next(
line for line in output_text.splitlines() if line.startswith("AMPG_APPLY_ARTIFACT") line for line in output_text.splitlines() if line.startswith("AMPG_APPLY_ARTIFACT")
+6 -2
View File
@@ -189,7 +189,7 @@ daemon_policy = "auto"
def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self): def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp).resolve()
config = load_config( config = load_config(
_write_config( _write_config(
root, root,
@@ -223,8 +223,12 @@ tunnel_name = "example-web"
set(by_name), set(by_name),
) )
self.assertIn("[example-web]", by_name["i2pd-tunnels.conf"].content) self.assertIn("[example-web]", by_name["i2pd-tunnels.conf"].content)
self.assertIn("keys =", by_name["i2pd-tunnels.conf"].content) self.assertIn(
f"keys = {root / '.ampg/state/example/i2p/example-web.dat'}",
by_name["i2pd-tunnels.conf"].content,
)
self.assertIn("listen 127.0.0.1:19081", by_name["nginx-loopback.conf"].content) self.assertIn("listen 127.0.0.1:19081", by_name["nginx-loopback.conf"].content)
self.assertIn(f"mkdir -p {root / '.ampg/state/example/i2p'}", by_name["i2pd.run"].content)
self.assertIn("exec i2pd", by_name["i2pd.run"].content) self.assertIn("exec i2pd", by_name["i2pd.run"].content)
self.assertIn("exec nginx", by_name["nginx-loopback.run"].content) self.assertIn("exec nginx", by_name["nginx-loopback.run"].content)