From f0d26933e6b279425a7204f3c923ffd52458ebb0 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 8 Jul 2026 10:12:59 -0400 Subject: [PATCH] Model transport provider sources --- ampg/cli.py | 1 + ampg/docsgen.py | 4 ++ ampg/metadata.py | 14 ++++++ ampg/status.py | 18 ++++++++ ampg/transports.py | 73 +++++++++++++++++++++++++++++++ docs/daemon-management.md | 29 +++++++++--- docs/generated/daemon-adapters.md | 6 +++ tests/test_install_plan.py | 3 +- tests/test_protocol_selection.py | 5 ++- tests/test_status.py | 3 ++ 10 files changed, 148 insertions(+), 8 deletions(-) diff --git a/ampg/cli.py b/ampg/cli.py index 2d3747a..54bcba0 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -1634,6 +1634,7 @@ def _print_transport_status(status: TransportStatus) -> None: f"supervisor={status.supervisor} " f"adapter={status.adapter} " f"backend={status.backend} " + f"provider_source={status.provider_source} " f"installed={_bool(status.installed)} " f"running={_bool(status.running)} " f"adoptable={_bool(status.adoptable)} " diff --git a/ampg/docsgen.py b/ampg/docsgen.py index 4c0fe36..107c23b 100644 --- a/ampg/docsgen.py +++ b/ampg/docsgen.py @@ -73,6 +73,10 @@ def _daemon_adapters_doc() -> str: sections.append(f"- Protocols: {', '.join(adapter.protocols)}") sections.append(f"- Default policy: `{adapter.default_policy}`") 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("Notes:") for note in adapter.notes: diff --git a/ampg/metadata.py b/ampg/metadata.py index 5b56528..927cb3a 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -31,6 +31,7 @@ class DaemonAdapter: default_policy: str generated_artifacts: tuple[str, ...] notes: tuple[str, ...] + provider_sources: tuple[str, ...] = () @dataclass(frozen=True) @@ -512,6 +513,7 @@ DAEMON_ADAPTERS = ( "Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.", "Tor/I2P HTTP targets can use loopback-only server blocks.", ), + provider_sources=("configured", "system-adopted", "system-managed", "platform-package"), ), DaemonAdapter( daemon=DEFAULT_DAEMONS["tor"], @@ -522,6 +524,7 @@ DAEMON_ADAPTERS = ( "AMPG should preserve existing HiddenServiceDir material when adopting.", "Managed hidden-service keys belong under AMPG state.", ), + provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"), ), DaemonAdapter( daemon=DEFAULT_DAEMONS["i2p"], @@ -532,6 +535,7 @@ DAEMON_ADAPTERS = ( "Web tunnels should use web-specific key files.", "Existing RPC/P2P tunnel keys are not reused for web publishing.", ), + provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"), ), DaemonAdapter( daemon=DEFAULT_DAEMONS["gemini"], @@ -542,6 +546,7 @@ DAEMON_ADAPTERS = ( "Gemini serves generated Gemtext output directly.", "Certificate and key paths are plan values until install/apply support exists.", ), + provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"), ), DaemonAdapter( daemon=DEFAULT_DAEMONS["ipfs"], @@ -552,6 +557,7 @@ DAEMON_ADAPTERS = ( "IPFS output is static web content for a local gateway or later pinning.", "IPFS is content-addressed distribution, not an anonymity layer.", ), + provider_sources=("configured", "bundled-sidecar", "system-adopted", "system-managed", "platform-package"), ), DaemonAdapter( daemon=DEFAULT_DAEMONS["reticulum"], @@ -563,5 +569,13 @@ DAEMON_ADAPTERS = ( "Reticulum is resilient/private routing, not an anonymity layer.", "Physical interfaces may require explicit operator setup.", ), + provider_sources=( + "configured", + "bundled-sidecar", + "system-adopted", + "system-managed", + "platform-package", + "operator-interface", + ), ), ) diff --git a/ampg/status.py b/ampg/status.py index 2aa4dbb..d1d2d63 100644 --- a/ampg/status.py +++ b/ampg/status.py @@ -40,6 +40,7 @@ class TransportStatus: supervisor: str adapter: str backend: str + provider_source: str installed: bool running: bool adoptable: bool @@ -265,6 +266,11 @@ def _transport_status( supervisor=provider.process_supervisor, adapter=f"{adapter.protocol}/{adapter.daemon}", backend=adapter.backend, + provider_source=_provider_source_for_status( + action=action, + installed=daemon.installed, + executable_path=daemon.executable_path, + ), installed=daemon.installed, running=daemon.running, 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: pgrep = shutil.which("pgrep") if not pgrep: diff --git a/ampg/transports.py b/ampg/transports.py index d3d3d57..21d51e6 100644 --- a/ampg/transports.py +++ b/ampg/transports.py @@ -3,6 +3,14 @@ from __future__ import annotations from dataclasses import dataclass +@dataclass(frozen=True) +class ProviderSource: + kind: str + discovery: str + lifecycle: str + notes: tuple[str, ...] = () + + @dataclass(frozen=True) class TransportAdapter: protocol: str @@ -13,9 +21,48 @@ class TransportAdapter: managed_supported: bool default_ports: tuple[int, ...] = () generated_artifacts: tuple[str, ...] = () + provider_sources: tuple[ProviderSource, ...] = () 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 = ( TransportAdapter( protocol="clearnet", @@ -26,6 +73,7 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(80, 443), generated_artifacts=("nginx server block snippet",), + provider_sources=COMMON_SERVICE_PROVIDER_SOURCES, notes=( "Clearnet ingress is usually adopted because TLS and public vhost policy are operator-owned.", ), @@ -39,6 +87,7 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(18080,), generated_artifacts=("torrc hidden-service snippet", "loopback HTTP server block"), + provider_sources=USERSPACE_PROVIDER_SOURCES, notes=( "Adopted Tor keeps existing HiddenServiceDir material.", "Managed Tor stores onion service keys under AMPG state.", @@ -53,6 +102,18 @@ TRANSPORT_ADAPTERS = ( managed_supported=False, default_ports=(18080,), 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.",), ), TransportAdapter( @@ -64,6 +125,7 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(18081,), 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.",), ), TransportAdapter( @@ -75,6 +137,7 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(1965,), generated_artifacts=("Agate plan values",), + provider_sources=USERSPACE_PROVIDER_SOURCES, notes=("Gemini serves generated Gemtext output directly.",), ), TransportAdapter( @@ -86,6 +149,7 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(5001, 8080), generated_artifacts=("pin/add plan",), + provider_sources=USERSPACE_PROVIDER_SOURCES, notes=("IPFS is content-addressed distribution, not an anonymity layer.",), ), TransportAdapter( @@ -97,6 +161,15 @@ TRANSPORT_ADAPTERS = ( managed_supported=True, default_ports=(), 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=( "Reticulum is resilient/private routing, not an anonymity layer.", "Physical interfaces may require operator setup outside AMPG.", diff --git a/docs/daemon-management.md b/docs/daemon-management.md index c2080a1..0552580 100644 --- a/docs/daemon-management.md +++ b/docs/daemon-management.md @@ -22,17 +22,36 @@ Managed daemons: - use AMPG-owned ports, sockets, hidden-service dirs, key files, and output roots. - 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 For each enabled protocol, AMPG checks: 1. Explicit config in `gateway.toml`. -2. AMPG-owned state from previous runs. -3. Common system services and ports. -4. Known config paths for supported daemons. +2. Bundled AMPG providers for the current platform. +3. AMPG-owned state from previous runs. +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 -config artifacts. +The result is shown in `ampg status` as `provider_source`. `ampg plan` shows rendered +outputs and reviewable config artifacts. ## Preflight commands diff --git a/docs/generated/daemon-adapters.md b/docs/generated/daemon-adapters.md index 07092c1..e0b6000 100644 --- a/docs/generated/daemon-adapters.md +++ b/docs/generated/daemon-adapters.md @@ -10,6 +10,7 @@ This file is generated from code. Do not edit it by hand. - Protocols: clearnet, tor, i2p - Default policy: `adopt` - Generated artifacts: server block snippets +- Provider sources: `configured`, `system-adopted`, `system-managed`, `platform-package` Notes: - Clearnet defaults to adopt because TLS and public vhost policy are operator-owned. @@ -20,6 +21,7 @@ Notes: - Protocols: tor - Default policy: `auto` - Generated artifacts: torrc hidden-service snippet +- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package` Notes: - AMPG should preserve existing HiddenServiceDir material when adopting. @@ -30,6 +32,7 @@ Notes: - Protocols: i2p - Default policy: `auto` - Generated artifacts: i2pd server tunnel snippet +- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package` Notes: - Web tunnels should use web-specific key files. @@ -40,6 +43,7 @@ Notes: - Protocols: gemini - Default policy: `auto` - Generated artifacts: Agate plan values +- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package` Notes: - Gemini serves generated Gemtext output directly. @@ -50,6 +54,7 @@ Notes: - Protocols: ipfs - Default policy: `auto` - Generated artifacts: fixture manifest route expectations +- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package` Notes: - IPFS output is static web content for a local gateway or later pinning. @@ -60,6 +65,7 @@ Notes: - Protocols: reticulum - Default policy: `auto` - Generated artifacts: Reticulum page-service plan +- Provider sources: `configured`, `bundled-sidecar`, `system-adopted`, `system-managed`, `platform-package`, `operator-interface` Notes: - Reticulum output is planned as small page-service content. diff --git a/tests/test_install_plan.py b/tests/test_install_plan.py index ce5aeb3..bf7bddb 100644 --- a/tests/test_install_plan.py +++ b/tests/test_install_plan.py @@ -3,6 +3,7 @@ import io import tempfile import unittest from pathlib import Path +from unittest.mock import patch from ampg.approvals import ApprovalInput, approve_artifacts from ampg.cli import main @@ -170,7 +171,7 @@ daemon_policy = "auto" ) stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None): status = main( [ "--config", diff --git a/tests/test_protocol_selection.py b/tests/test_protocol_selection.py index 3c3ac7b..04a0047 100644 --- a/tests/test_protocol_selection.py +++ b/tests/test_protocol_selection.py @@ -3,6 +3,7 @@ import io import tempfile import unittest from pathlib import Path +from unittest.mock import patch from ampg.cli import main 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") stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None): status = main( [ "--config", @@ -179,7 +180,7 @@ class ProtocolSelectionTest(unittest.TestCase): (output / ".ampg-output").write_text("generated by AMPG\n", encoding="utf-8") stdout = io.StringIO() - with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stdout(stdout), patch("ampg.status.shutil.which", return_value=None): status = main( [ "--config", diff --git a/tests/test_status.py b/tests/test_status.py index b7ecb9e..b65e892 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -74,6 +74,7 @@ daemon_policy = "auto" self.assertEqual(1, len(statuses)) self.assertEqual("ok", statuses[0].status) self.assertEqual("adopt-existing", statuses[0].action) + self.assertEqual("system-adopted", statuses[0].provider_source) self.assertTrue(statuses[0].adoptable) 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("manage-owned", statuses[0].action) + self.assertEqual("platform-package", statuses[0].provider_source) self.assertTrue(statuses[0].manageable) 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("unavailable", statuses[0].action) + self.assertEqual("unavailable", statuses[0].provider_source) self.assertIn("nginx is not installed", statuses[0].message) def test_unknown_adapter_errors_unless_external(self):