mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add address capture to activation plan
This commit is contained in:
@@ -140,12 +140,16 @@ passed. They are reviewable snippets, not installed daemon config.
|
||||
|
||||
Managed install artifacts are also written under `dist/ampg-plan/` when
|
||||
`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
|
||||
`addresses capture` or `addresses set`. Fixture manifests and health plans use explicit
|
||||
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
|
||||
lets a full site config build or activate only Tor, only I2P, or a selected subset without
|
||||
unselected transports blocking the run.
|
||||
|
||||
+80
-8
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
|
||||
from .addresses import AddressRecord, address_registry_path, effective_address_records
|
||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||
from .plan import plan_artifacts, write_plan_artifacts
|
||||
from .platforms import PlatformProvider
|
||||
@@ -23,6 +25,7 @@ class ActivationStep:
|
||||
target: str
|
||||
status: str
|
||||
message: str
|
||||
command: str = "-"
|
||||
|
||||
|
||||
def activation_steps(
|
||||
@@ -39,6 +42,10 @@ def activation_steps(
|
||||
status_by_protocol = {
|
||||
(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] = []
|
||||
|
||||
for issue in doctor_gateway(
|
||||
@@ -69,9 +76,11 @@ def activation_steps(
|
||||
transport_status = status_by_protocol[(site.id, protocol.name)]
|
||||
steps.extend(
|
||||
_protocol_steps(
|
||||
config,
|
||||
site,
|
||||
protocol,
|
||||
transport_status=transport_status,
|
||||
address_record=address_by_protocol[(site.id, protocol.name)],
|
||||
output_ready=output_ready,
|
||||
)
|
||||
)
|
||||
@@ -84,10 +93,12 @@ def blocked_steps(steps: list[ActivationStep]) -> list[ActivationStep]:
|
||||
|
||||
|
||||
def _protocol_steps(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
*,
|
||||
transport_status: TransportStatus,
|
||||
address_record: AddressRecord,
|
||||
output_ready: bool,
|
||||
) -> list[ActivationStep]:
|
||||
output_root = site.outputs.root / protocol.name
|
||||
@@ -145,6 +156,7 @@ def _protocol_steps(
|
||||
message=transport_status.message,
|
||||
)
|
||||
)
|
||||
steps.append(_address_step(config, site, protocol, address_record))
|
||||
steps.append(
|
||||
ActivationStep(
|
||||
site_id=site.id,
|
||||
@@ -152,21 +164,81 @@ def _protocol_steps(
|
||||
stage="health",
|
||||
action="check-transport",
|
||||
target=protocol.name,
|
||||
status=(
|
||||
"planned"
|
||||
if output_ready and transport_status.status != "error"
|
||||
else "blocked"
|
||||
status=_health_activation_status(
|
||||
output_ready=output_ready,
|
||||
transport_status=transport_status,
|
||||
address_record=address_record,
|
||||
),
|
||||
message=(
|
||||
"verify generated site through this transport after apply"
|
||||
if output_ready and transport_status.status != "error"
|
||||
else "health check is blocked until output and daemon steps are ready"
|
||||
message=_health_activation_message(
|
||||
output_ready=output_ready,
|
||||
transport_status=transport_status,
|
||||
address_record=address_record,
|
||||
),
|
||||
)
|
||||
)
|
||||
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:
|
||||
return ((site.outputs.root / protocol.name) / ".ampg-output").exists()
|
||||
|
||||
|
||||
@@ -524,6 +524,7 @@ def _print_activation_step(step: ActivationStep) -> None:
|
||||
f"action={step.action} "
|
||||
f"target={step.target} "
|
||||
f"status={step.status} "
|
||||
f"command=\"{_quote(step.command)}\" "
|
||||
f"message=\"{_quote(step.message)}\""
|
||||
)
|
||||
|
||||
|
||||
+30
-18
@@ -53,7 +53,7 @@ def install_plan(
|
||||
if not protocol.enabled:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def install_artifacts(
|
||||
if not protocol.enabled:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ def write_install_artifacts(
|
||||
|
||||
|
||||
def _protocol_install_steps(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
@@ -170,9 +171,9 @@ def _protocol_install_steps(
|
||||
provider,
|
||||
stage="state",
|
||||
action="create-state",
|
||||
target=str(_state_dir(provider, site, protocol)),
|
||||
target=str(_state_dir(config, provider, site, protocol)),
|
||||
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",
|
||||
),
|
||||
_step(
|
||||
@@ -181,7 +182,7 @@ def _protocol_install_steps(
|
||||
provider,
|
||||
stage="config",
|
||||
action="write-managed-config",
|
||||
target=str(_state_dir(provider, site, protocol) / "config"),
|
||||
target=str(_state_dir(config, provider, site, protocol) / "config"),
|
||||
status="planned",
|
||||
command="-",
|
||||
message=f"write managed {adapter.backend} config after plan review",
|
||||
@@ -212,6 +213,7 @@ def _protocol_install_steps(
|
||||
|
||||
|
||||
def _protocol_install_artifacts(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
@@ -227,7 +229,7 @@ def _protocol_install_artifacts(
|
||||
provider,
|
||||
kind="daemon-config",
|
||||
filename=_daemon_config_filename(protocol),
|
||||
content=_daemon_config_content(site, protocol, provider),
|
||||
content=_daemon_config_content(config, site, protocol, provider),
|
||||
),
|
||||
_artifact(
|
||||
site,
|
||||
@@ -236,11 +238,12 @@ def _protocol_install_artifacts(
|
||||
kind="daemon-supervisor",
|
||||
filename=_supervisor_filename(provider, protocol.daemon),
|
||||
content=_supervisor_content(
|
||||
config,
|
||||
site,
|
||||
protocol,
|
||||
provider,
|
||||
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,
|
||||
kind="loopback-config",
|
||||
filename="nginx-loopback.conf",
|
||||
content=_managed_nginx_loopback_content(site, protocol, provider),
|
||||
content=_managed_nginx_loopback_content(config, site, protocol, provider),
|
||||
),
|
||||
_artifact(
|
||||
site,
|
||||
@@ -262,11 +265,12 @@ def _protocol_install_artifacts(
|
||||
kind="loopback-supervisor",
|
||||
filename=_supervisor_filename(provider, "nginx-loopback"),
|
||||
content=_supervisor_content(
|
||||
config,
|
||||
site,
|
||||
protocol,
|
||||
provider,
|
||||
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(
|
||||
config: GatewayConfig,
|
||||
provider: PlatformProvider,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
) -> 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(
|
||||
@@ -387,11 +393,12 @@ def _daemon_config_filename(protocol: ProtocolConfig) -> str:
|
||||
|
||||
|
||||
def _daemon_config_content(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
) -> str:
|
||||
state_dir = _state_dir(provider, site, protocol)
|
||||
state_dir = _state_dir(config, provider, site, protocol)
|
||||
if protocol.name == "tor":
|
||||
port = int(protocol.options.get("local_port", 18080))
|
||||
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"}
|
||||
"""
|
||||
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.
|
||||
daemon = {protocol.daemon}
|
||||
protocol = {protocol.name}
|
||||
@@ -433,11 +440,12 @@ state_dir = {state_dir}
|
||||
|
||||
|
||||
def _managed_nginx_server_content(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
) -> str:
|
||||
state_dir = _state_dir(provider, site, protocol)
|
||||
state_dir = _state_dir(config, provider, site, protocol)
|
||||
return f"""# Generated by AMPG. Review before installing.
|
||||
worker_processes 1;
|
||||
error_log {state_dir / "nginx-error.log"} notice;
|
||||
@@ -465,11 +473,12 @@ http {{
|
||||
|
||||
|
||||
def _managed_nginx_loopback_content(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
) -> 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))
|
||||
return f"""# Generated by AMPG. Review before installing.
|
||||
worker_processes 1;
|
||||
@@ -505,6 +514,7 @@ def _supervisor_filename(provider: PlatformProvider, service_suffix: str) -> str
|
||||
|
||||
|
||||
def _supervisor_content(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
@@ -522,7 +532,7 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={_state_dir(provider, site, protocol)}
|
||||
WorkingDirectory={_state_dir(config, provider, site, protocol)}
|
||||
ExecStart=/bin/sh -lc '{_shell_single_quote(command)}'
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
@@ -555,17 +565,18 @@ WantedBy=multi-user.target
|
||||
return f"""#!{shell}
|
||||
# Generated by AMPG. Review before installing.
|
||||
set -eu
|
||||
mkdir -p {_state_dir(provider, site, protocol)}
|
||||
mkdir -p {_state_dir(config, provider, site, protocol)}
|
||||
{command}
|
||||
"""
|
||||
|
||||
|
||||
def _daemon_start_command(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
) -> str:
|
||||
state_dir = _state_dir(provider, site, protocol)
|
||||
state_dir = _state_dir(config, provider, site, protocol)
|
||||
if protocol.name == "tor":
|
||||
return f"exec tor -f {state_dir / 'torrc'}"
|
||||
if protocol.name == "i2p":
|
||||
@@ -585,11 +596,12 @@ def _daemon_start_command(
|
||||
|
||||
|
||||
def _loopback_start_command(
|
||||
config: GatewayConfig,
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
provider: PlatformProvider,
|
||||
) -> 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;'"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
layer in the install plan, because the hidden service or tunnel must have a local HTTP
|
||||
|
||||
@@ -112,8 +112,8 @@ workflow.
|
||||
`[gateway]` paths are resolved relative to `gateway.toml` when they are not absolute.
|
||||
|
||||
`state_dir`
|
||||
: AMPG-owned durable state. Managed daemon identities, generated transport state, and
|
||||
the captured address registry live here.
|
||||
: AMPG-owned durable state. Managed daemon identities, generated transport state, daemon
|
||||
logs, daemon-written addresses, and the captured address registry live here.
|
||||
|
||||
`cache_dir`
|
||||
: Rebuildable intermediate data.
|
||||
|
||||
@@ -66,7 +66,8 @@ blocked.
|
||||
`install-plan --write-artifacts` writes managed daemon configs and platform supervisor
|
||||
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
|
||||
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:
|
||||
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.
|
||||
|
||||
`apply --dry-run` prints the activation sequence for each enabled protocol: generated
|
||||
output readiness, config artifacts to review, daemon action, and post-apply health
|
||||
checks. It exits nonzero when any step is blocked. `--write-artifacts` writes reviewable
|
||||
config snippets to the configured plan root but still does not install or reload
|
||||
services.
|
||||
output readiness, config artifacts to review, daemon action, address capture or reuse,
|
||||
and post-apply health checks. It exits nonzero when any step is blocked. `--write-artifacts`
|
||||
writes reviewable config snippets to the configured plan root but still does not install
|
||||
or reload services.
|
||||
|
||||
`--protocol` scopes operational commands to selected enabled protocols. A clearnet
|
||||
`adopt` failure will block a full activation run, but it will not block
|
||||
|
||||
@@ -50,7 +50,7 @@ plan_root = "./plan"
|
||||
|
||||
|
||||
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:
|
||||
root = Path(tmp)
|
||||
config_path = _write_config(
|
||||
@@ -79,9 +79,42 @@ local_port = 19081
|
||||
by_stage = {step.stage: step for step in steps if step.stage != "artifact"}
|
||||
self.assertEqual("ready", by_stage["output"].status)
|
||||
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))
|
||||
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_path = _write_config(
|
||||
@@ -162,6 +195,7 @@ port = 1965
|
||||
output_text = stdout.getvalue()
|
||||
self.assertIn("AMPG_APPLY_ARTIFACT", output_text)
|
||||
self.assertIn("AMPG_APPLY_STEP", output_text)
|
||||
self.assertIn('command="-"', output_text)
|
||||
self.assertIn("blocked=0", output_text)
|
||||
artifact_line = next(
|
||||
line for line in output_text.splitlines() if line.startswith("AMPG_APPLY_ARTIFACT")
|
||||
|
||||
@@ -189,7 +189,7 @@ daemon_policy = "auto"
|
||||
|
||||
def test_managed_i2p_artifacts_include_daemon_loopback_and_supervisors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
root = Path(tmp).resolve()
|
||||
config = load_config(
|
||||
_write_config(
|
||||
root,
|
||||
@@ -223,8 +223,12 @@ tunnel_name = "example-web"
|
||||
set(by_name),
|
||||
)
|
||||
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(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 nginx", by_name["nginx-loopback.run"].content)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user