Model transport provider sources

This commit is contained in:
jwinterm
2026-07-08 10:12:59 -04:00
parent e272ec1afb
commit f0d26933e6
10 changed files with 148 additions and 8 deletions
+1
View File
@@ -1634,6 +1634,7 @@ def _print_transport_status(status: TransportStatus) -> None:
f"supervisor={status.supervisor} " f"supervisor={status.supervisor} "
f"adapter={status.adapter} " f"adapter={status.adapter} "
f"backend={status.backend} " f"backend={status.backend} "
f"provider_source={status.provider_source} "
f"installed={_bool(status.installed)} " f"installed={_bool(status.installed)} "
f"running={_bool(status.running)} " f"running={_bool(status.running)} "
f"adoptable={_bool(status.adoptable)} " f"adoptable={_bool(status.adoptable)} "
+4
View File
@@ -73,6 +73,10 @@ def _daemon_adapters_doc() -> str:
sections.append(f"- Protocols: {', '.join(adapter.protocols)}") sections.append(f"- Protocols: {', '.join(adapter.protocols)}")
sections.append(f"- Default policy: `{adapter.default_policy}`") sections.append(f"- Default policy: `{adapter.default_policy}`")
sections.append(f"- Generated artifacts: {', '.join(adapter.generated_artifacts)}") sections.append(f"- Generated artifacts: {', '.join(adapter.generated_artifacts)}")
sections.append(
"- Provider sources: "
+ (", ".join(f"`{source}`" for source in adapter.provider_sources) or "-")
)
sections.append("") sections.append("")
sections.append("Notes:") sections.append("Notes:")
for note in adapter.notes: for note in adapter.notes:
+14
View File
@@ -31,6 +31,7 @@ class DaemonAdapter:
default_policy: str default_policy: str
generated_artifacts: tuple[str, ...] generated_artifacts: tuple[str, ...]
notes: tuple[str, ...] notes: tuple[str, ...]
provider_sources: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -512,6 +513,7 @@ DAEMON_ADAPTERS = (
"Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.", "Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.",
"Tor/I2P HTTP targets can use loopback-only server blocks.", "Tor/I2P HTTP targets can use loopback-only server blocks.",
), ),
provider_sources=("configured", "system-adopted", "system-managed", "platform-package"),
), ),
DaemonAdapter( DaemonAdapter(
daemon=DEFAULT_DAEMONS["tor"], daemon=DEFAULT_DAEMONS["tor"],
@@ -522,6 +524,7 @@ DAEMON_ADAPTERS = (
"AMPG should preserve existing HiddenServiceDir material when adopting.", "AMPG should preserve existing HiddenServiceDir material when adopting.",
"Managed hidden-service keys belong under AMPG state.", "Managed hidden-service keys belong under AMPG state.",
), ),
provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"),
), ),
DaemonAdapter( DaemonAdapter(
daemon=DEFAULT_DAEMONS["i2p"], daemon=DEFAULT_DAEMONS["i2p"],
@@ -532,6 +535,7 @@ DAEMON_ADAPTERS = (
"Web tunnels should use web-specific key files.", "Web tunnels should use web-specific key files.",
"Existing RPC/P2P tunnel keys are not reused for web publishing.", "Existing RPC/P2P tunnel keys are not reused for web publishing.",
), ),
provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"),
), ),
DaemonAdapter( DaemonAdapter(
daemon=DEFAULT_DAEMONS["gemini"], daemon=DEFAULT_DAEMONS["gemini"],
@@ -542,6 +546,7 @@ DAEMON_ADAPTERS = (
"Gemini serves generated Gemtext output directly.", "Gemini serves generated Gemtext output directly.",
"Certificate and key paths are plan values until install/apply support exists.", "Certificate and key paths are plan values until install/apply support exists.",
), ),
provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"),
), ),
DaemonAdapter( DaemonAdapter(
daemon=DEFAULT_DAEMONS["ipfs"], daemon=DEFAULT_DAEMONS["ipfs"],
@@ -552,6 +557,7 @@ DAEMON_ADAPTERS = (
"IPFS output is static web content for a local gateway or later pinning.", "IPFS output is static web content for a local gateway or later pinning.",
"IPFS is content-addressed distribution, not an anonymity layer.", "IPFS is content-addressed distribution, not an anonymity layer.",
), ),
provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"),
), ),
DaemonAdapter( DaemonAdapter(
daemon=DEFAULT_DAEMONS["reticulum"], daemon=DEFAULT_DAEMONS["reticulum"],
@@ -563,5 +569,13 @@ DAEMON_ADAPTERS = (
"Reticulum is resilient/private routing, not an anonymity layer.", "Reticulum is resilient/private routing, not an anonymity layer.",
"Physical interfaces may require explicit operator setup.", "Physical interfaces may require explicit operator setup.",
), ),
provider_sources=(
"configured",
"bundled-sidecar",
"system-adopted",
"system-managed",
"platform-package",
"operator-interface",
),
), ),
) )
+18
View File
@@ -40,6 +40,7 @@ class TransportStatus:
supervisor: str supervisor: str
adapter: str adapter: str
backend: str backend: str
provider_source: str
installed: bool installed: bool
running: bool running: bool
adoptable: bool adoptable: bool
@@ -265,6 +266,11 @@ def _transport_status(
supervisor=provider.process_supervisor, supervisor=provider.process_supervisor,
adapter=f"{adapter.protocol}/{adapter.daemon}", adapter=f"{adapter.protocol}/{adapter.daemon}",
backend=adapter.backend, backend=adapter.backend,
provider_source=_provider_source_for_status(
action=action,
installed=daemon.installed,
executable_path=daemon.executable_path,
),
installed=daemon.installed, installed=daemon.installed,
running=daemon.running, running=daemon.running,
adoptable=adoptable, adoptable=adoptable,
@@ -276,6 +282,18 @@ def _transport_status(
) )
def _provider_source_for_status(*, action: str, installed: bool, executable_path: str | None) -> str:
if action == "adopt-existing":
return "system-adopted"
if action == "manage-owned":
if installed or executable_path:
return "system-managed"
return "platform-package"
if action == "render-only":
return "external"
return "unavailable"
def _process_running(executable: str) -> bool: def _process_running(executable: str) -> bool:
pgrep = shutil.which("pgrep") pgrep = shutil.which("pgrep")
if not pgrep: if not pgrep:
+73
View File
@@ -3,6 +3,14 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@dataclass(frozen=True)
class ProviderSource:
kind: str
discovery: str
lifecycle: str
notes: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class TransportAdapter: class TransportAdapter:
protocol: str protocol: str
@@ -13,9 +21,48 @@ class TransportAdapter:
managed_supported: bool managed_supported: bool
default_ports: tuple[int, ...] = () default_ports: tuple[int, ...] = ()
generated_artifacts: tuple[str, ...] = () generated_artifacts: tuple[str, ...] = ()
provider_sources: tuple[ProviderSource, ...] = ()
notes: tuple[str, ...] = () notes: tuple[str, ...] = ()
COMMON_SERVICE_PROVIDER_SOURCES = (
ProviderSource(
kind="configured",
discovery="explicit daemon config and approved generated snippets",
lifecycle="operator-owned unless daemon_policy selects manage",
notes=("Explicit config always wins over automatic discovery.",),
),
ProviderSource(
kind="system-adopted",
discovery="running system daemon, service, or listen endpoint",
lifecycle="operator-owned; AMPG may generate snippets but does not own the service",
notes=("Used when daemon_policy is adopt or auto with a healthy daemon.",),
),
ProviderSource(
kind="system-managed",
discovery="installed daemon binary on the selected platform",
lifecycle="AMPG-owned state and AMPG-named supervisor entry",
notes=("Used when daemon_policy is manage or auto and the platform can supervise it.",),
),
ProviderSource(
kind="platform-package",
discovery="allowlisted package backend such as apt, brew, or pkg",
lifecycle="installed only through reviewed deploy apply package stage",
notes=("Package install is separate from state, supervisor, start, address, and health stages.",),
),
)
USERSPACE_PROVIDER_SOURCES = COMMON_SERVICE_PROVIDER_SOURCES + (
ProviderSource(
kind="bundled-sidecar",
discovery="AMPG package or app resource provider directory",
lifecycle="AMPG-owned process and state",
notes=("Useful for old laptops, single-purpose boxes, and mobile-server shells.",),
),
)
TRANSPORT_ADAPTERS = ( TRANSPORT_ADAPTERS = (
TransportAdapter( TransportAdapter(
protocol="clearnet", protocol="clearnet",
@@ -26,6 +73,7 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(80, 443), default_ports=(80, 443),
generated_artifacts=("nginx server block snippet",), generated_artifacts=("nginx server block snippet",),
provider_sources=COMMON_SERVICE_PROVIDER_SOURCES,
notes=( notes=(
"Clearnet ingress is usually adopted because TLS and public vhost policy are operator-owned.", "Clearnet ingress is usually adopted because TLS and public vhost policy are operator-owned.",
), ),
@@ -39,6 +87,7 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(18080,), default_ports=(18080,),
generated_artifacts=("torrc hidden-service snippet", "loopback HTTP server block"), generated_artifacts=("torrc hidden-service snippet", "loopback HTTP server block"),
provider_sources=USERSPACE_PROVIDER_SOURCES,
notes=( notes=(
"Adopted Tor keeps existing HiddenServiceDir material.", "Adopted Tor keeps existing HiddenServiceDir material.",
"Managed Tor stores onion service keys under AMPG state.", "Managed Tor stores onion service keys under AMPG state.",
@@ -53,6 +102,18 @@ TRANSPORT_ADAPTERS = (
managed_supported=False, managed_supported=False,
default_ports=(18080,), default_ports=(18080,),
generated_artifacts=("future Arti onion-service config",), generated_artifacts=("future Arti onion-service config",),
provider_sources=(
ProviderSource(
kind="bundled-sidecar",
discovery="AMPG package or app resource provider directory",
lifecycle="future AMPG-owned Arti onion-service process",
),
ProviderSource(
kind="configured",
discovery="explicit arti binary path and onion-service config",
lifecycle="future AMPG-owned or operator-owned by policy",
),
),
notes=("Modeled as a future backend until the AMPG Arti adapter is implemented.",), notes=("Modeled as a future backend until the AMPG Arti adapter is implemented.",),
), ),
TransportAdapter( TransportAdapter(
@@ -64,6 +125,7 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(18081,), default_ports=(18081,),
generated_artifacts=("i2pd server tunnel snippet", "loopback HTTP server block"), generated_artifacts=("i2pd server tunnel snippet", "loopback HTTP server block"),
provider_sources=USERSPACE_PROVIDER_SOURCES,
notes=("Destination keys are transport identity material and must be preserved.",), notes=("Destination keys are transport identity material and must be preserved.",),
), ),
TransportAdapter( TransportAdapter(
@@ -75,6 +137,7 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(1965,), default_ports=(1965,),
generated_artifacts=("Agate plan values",), generated_artifacts=("Agate plan values",),
provider_sources=USERSPACE_PROVIDER_SOURCES,
notes=("Gemini serves generated Gemtext output directly.",), notes=("Gemini serves generated Gemtext output directly.",),
), ),
TransportAdapter( TransportAdapter(
@@ -86,6 +149,7 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(5001, 8080), default_ports=(5001, 8080),
generated_artifacts=("pin/add plan",), generated_artifacts=("pin/add plan",),
provider_sources=USERSPACE_PROVIDER_SOURCES,
notes=("IPFS is content-addressed distribution, not an anonymity layer.",), notes=("IPFS is content-addressed distribution, not an anonymity layer.",),
), ),
TransportAdapter( TransportAdapter(
@@ -97,6 +161,15 @@ TRANSPORT_ADAPTERS = (
managed_supported=True, managed_supported=True,
default_ports=(), default_ports=(),
generated_artifacts=("Reticulum page-service plan",), generated_artifacts=("Reticulum page-service plan",),
provider_sources=USERSPACE_PROVIDER_SOURCES
+ (
ProviderSource(
kind="operator-interface",
discovery="explicit Reticulum interface configuration",
lifecycle="operator-owned physical or link-layer interface",
notes=("AMPG can manage page serving while interface setup may remain manual.",),
),
),
notes=( notes=(
"Reticulum is resilient/private routing, not an anonymity layer.", "Reticulum is resilient/private routing, not an anonymity layer.",
"Physical interfaces may require operator setup outside AMPG.", "Physical interfaces may require operator setup outside AMPG.",
+24 -5
View File
@@ -22,17 +22,36 @@ Managed daemons:
- use AMPG-owned ports, sockets, hidden-service dirs, key files, and output roots. - use AMPG-owned ports, sockets, hidden-service dirs, key files, and output roots.
- can be stopped, restarted, or removed by AMPG. - can be stopped, restarted, or removed by AMPG.
## Provider model
A transport provider is the implementation AMPG uses to publish one protocol target.
Every provider must resolve to a local endpoint, generated config, managed state, and a
health check.
Provider sources:
- `configured`: explicit config from `gateway.toml` or approved generated snippets.
- `bundled-sidecar`: a provider shipped with AMPG or an app bundle and run from AMPG
state.
- `system-adopted`: a healthy daemon that AMPG did not start and does not own.
- `system-managed`: an installed daemon binary supervised through AMPG-owned state.
- `platform-package`: a daemon package installed by an allowlisted apply stage.
- `operator-interface`: physical or link-layer setup that remains operator-owned.
- `external`: render outputs and review artifacts only.
## Discovery order ## Discovery order
For each enabled protocol, AMPG checks: For each enabled protocol, AMPG checks:
1. Explicit config in `gateway.toml`. 1. Explicit config in `gateway.toml`.
2. AMPG-owned state from previous runs. 2. Bundled AMPG providers for the current platform.
3. Common system services and ports. 3. AMPG-owned state from previous runs.
4. Known config paths for supported daemons. 4. Common system services and ports.
5. Known config paths for supported daemons.
6. Platform package backends for managed install stages.
The result is shown in `ampg status`. `ampg plan` shows rendered outputs and reviewable The result is shown in `ampg status` as `provider_source`. `ampg plan` shows rendered
config artifacts. outputs and reviewable config artifacts.
## Preflight commands ## Preflight commands
+6
View File
@@ -10,6 +10,7 @@ This file is generated from code. Do not edit it by hand.
- Protocols: clearnet, tor, i2p - Protocols: clearnet, tor, i2p
- Default policy: `adopt` - Default policy: `adopt`
- Generated artifacts: server block snippets - Generated artifacts: server block snippets
- Provider sources: `configured`, `system-adopted`, `system-managed`, `platform-package`
Notes: Notes:
- Clearnet defaults to adopt because TLS and public vhost policy are operator-owned. - Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.
@@ -20,6 +21,7 @@ Notes:
- Protocols: tor - Protocols: tor
- Default policy: `auto` - Default policy: `auto`
- Generated artifacts: torrc hidden-service snippet - Generated artifacts: torrc hidden-service snippet
- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`
Notes: Notes:
- AMPG should preserve existing HiddenServiceDir material when adopting. - AMPG should preserve existing HiddenServiceDir material when adopting.
@@ -30,6 +32,7 @@ Notes:
- Protocols: i2p - Protocols: i2p
- Default policy: `auto` - Default policy: `auto`
- Generated artifacts: i2pd server tunnel snippet - Generated artifacts: i2pd server tunnel snippet
- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`
Notes: Notes:
- Web tunnels should use web-specific key files. - Web tunnels should use web-specific key files.
@@ -40,6 +43,7 @@ Notes:
- Protocols: gemini - Protocols: gemini
- Default policy: `auto` - Default policy: `auto`
- Generated artifacts: Agate plan values - Generated artifacts: Agate plan values
- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`
Notes: Notes:
- Gemini serves generated Gemtext output directly. - Gemini serves generated Gemtext output directly.
@@ -50,6 +54,7 @@ Notes:
- Protocols: ipfs - Protocols: ipfs
- Default policy: `auto` - Default policy: `auto`
- Generated artifacts: fixture manifest route expectations - Generated artifacts: fixture manifest route expectations
- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`
Notes: Notes:
- IPFS output is static web content for a local gateway or later pinning. - IPFS output is static web content for a local gateway or later pinning.
@@ -60,6 +65,7 @@ Notes:
- Protocols: reticulum - Protocols: reticulum
- Default policy: `auto` - Default policy: `auto`
- Generated artifacts: Reticulum page-service plan - Generated artifacts: Reticulum page-service plan
- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`, `operator-interface`
Notes: Notes:
- Reticulum output is planned as small page-service content. - Reticulum output is planned as small page-service content.
+2 -1
View File
@@ -3,6 +3,7 @@ import io
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from ampg.approvals import ApprovalInput, approve_artifacts from ampg.approvals import ApprovalInput, approve_artifacts
from ampg.cli import main from ampg.cli import main
@@ -170,7 +171,7 @@ daemon_policy = "auto"
) )
stdout = io.StringIO() stdout = io.StringIO()
with contextlib.redirect_stdout(stdout): with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None):
status = main( status = main(
[ [
"--config", "--config",
+3 -2
View File
@@ -3,6 +3,7 @@ import io
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from ampg.cli import main from ampg.cli import main
from ampg.config import load_config from ampg.config import load_config
@@ -149,7 +150,7 @@ class ProtocolSelectionTest(unittest.TestCase):
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
stdout = io.StringIO() stdout = io.StringIO()
with contextlib.redirect_stdout(stdout): with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None):
status = main( status = main(
[ [
"--config", "--config",
@@ -179,7 +180,7 @@ class ProtocolSelectionTest(unittest.TestCase):
(output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8")
stdout = io.StringIO() stdout = io.StringIO()
with contextlib.redirect_stdout(stdout): with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None):
status = main( status = main(
[ [
"--config", "--config",
+3
View File
@@ -74,6 +74,7 @@ daemon_policy = "auto"
self.assertEqual(1, len(statuses)) self.assertEqual(1, len(statuses))
self.assertEqual("ok", statuses[0].status) self.assertEqual("ok", statuses[0].status)
self.assertEqual("adopt-existing", statuses[0].action) self.assertEqual("adopt-existing", statuses[0].action)
self.assertEqual("system-adopted", statuses[0].provider_source)
self.assertTrue(statuses[0].adoptable) self.assertTrue(statuses[0].adoptable)
def test_auto_policy_manages_missing_daemon_on_manageable_platform(self): def test_auto_policy_manages_missing_daemon_on_manageable_platform(self):
@@ -98,6 +99,7 @@ daemon_policy = "auto"
self.assertEqual("ok", statuses[0].status) self.assertEqual("ok", statuses[0].status)
self.assertEqual("manage-owned", statuses[0].action) self.assertEqual("manage-owned", statuses[0].action)
self.assertEqual("platform-package", statuses[0].provider_source)
self.assertTrue(statuses[0].manageable) self.assertTrue(statuses[0].manageable)
def test_adopt_policy_errors_when_daemon_is_missing(self): def test_adopt_policy_errors_when_daemon_is_missing(self):
@@ -122,6 +124,7 @@ daemon_policy = "adopt"
self.assertEqual("error", statuses[0].status) self.assertEqual("error", statuses[0].status)
self.assertEqual("unavailable", statuses[0].action) self.assertEqual("unavailable", statuses[0].action)
self.assertEqual("unavailable", statuses[0].provider_source)
self.assertIn("nginx is not installed", statuses[0].message) self.assertIn("nginx is not installed", statuses[0].message)
def test_unknown_adapter_errors_unless_external(self): def test_unknown_adapter_errors_unless_external(self):