Add managed state contract

This commit is contained in:
jwinterm
2026-07-06 11:06:11 -04:00
parent 12983ca134
commit b268e93634
8 changed files with 614 additions and 44 deletions
+2
View File
@@ -83,6 +83,7 @@ API remains internal even when the public site is available on Tor or I2P.
- [Configuration](docs/configuration.md) - [Configuration](docs/configuration.md)
- [Daemon management](docs/daemon-management.md) - [Daemon management](docs/daemon-management.md)
- [Fixture manifests](docs/fixture-manifests.md) - [Fixture manifests](docs/fixture-manifests.md)
- [State contract](docs/state-contract.md)
- [Interaction capabilities](docs/interaction-capabilities.md) - [Interaction capabilities](docs/interaction-capabilities.md)
- [Interactive applications](docs/interactive-apps.md) - [Interactive applications](docs/interactive-apps.md)
- [Wownero integration](docs/wownero-integration.md) - [Wownero integration](docs/wownero-integration.md)
@@ -119,6 +120,7 @@ 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 addresses capture --profile mobile-i2p python3 -m ampg --config examples/wownero.gateway.toml addresses capture --profile mobile-i2p
python3 -m ampg --config examples/wownero.gateway.toml addresses set --site wownero --protocol i2p --url example.b32.i2p python3 -m ampg --config examples/wownero.gateway.toml addresses set --site wownero --protocol i2p --url example.b32.i2p
python3 -m ampg --config examples/wownero.gateway.toml state-contract --profile mobile-i2p
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 routes explain python3 -m ampg --config examples/wownero.gateway.toml routes explain
+2 -41
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any from typing import Any
from .config import GatewayConfig, ProtocolConfig, SiteConfig from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .state_contract import address_capture_candidates
SCHEMA = "ampg.address-registry.v1" SCHEMA = "ampg.address-registry.v1"
@@ -244,7 +245,7 @@ def _capture_protocol_address(
message="address is set in config; registry unchanged", message="address is set in config; registry unchanged",
) )
candidates = _address_file_candidates(config, site, protocol) candidates = address_capture_candidates(config, site, protocol)
if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"): if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"):
return AddressCaptureResult( return AddressCaptureResult(
site_id=site.id, site_id=site.id,
@@ -284,41 +285,6 @@ def _capture_protocol_address(
) )
def _address_file_candidates(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> tuple[Path, ...]:
configured_file = protocol.options.get("address_file")
candidates: list[Path] = []
if configured_file:
path = Path(str(configured_file)).expanduser()
if not path.is_absolute():
path = config.config_path.parent / path
candidates.append(path.resolve())
state_dir = _state_dir(config, site, protocol)
if protocol.name == "tor":
candidates.extend(
[
state_dir / "hidden-service/hostname",
state_dir / "hostname",
state_dir / "address.txt",
]
)
elif protocol.name == "i2p":
candidates.extend(
[
state_dir / "hostname.txt",
state_dir / "b32.txt",
state_dir / "address.txt",
]
)
else:
candidates.extend([state_dir / "address.txt", state_dir / "hostname"])
return tuple(candidates)
def _placeholder_or_derived_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord: def _placeholder_or_derived_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord:
if protocol.name == "clearnet": if protocol.name == "clearnet":
url = site.source.canonical_url or f"https://{site.domain}/" url = site.source.canonical_url or f"https://{site.domain}/"
@@ -358,11 +324,6 @@ def _require_declared_protocol(config: GatewayConfig, site_id: str, protocol: st
raise ValueError(f"unknown site {site_id!r}") raise ValueError(f"unknown site {site_id!r}")
def _state_dir(config: GatewayConfig, site: SiteConfig, protocol: ProtocolConfig) -> Path:
root = config.paths.state_dir if config.paths else config.config_path.parent / ".ampg/state"
return root / site.id / protocol.name
def _first_nonempty_line(path: Path) -> str: def _first_nonempty_line(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines(): for line in path.read_text(encoding="utf-8").splitlines():
value = line.strip() value = line.strip()
+36
View File
@@ -40,6 +40,7 @@ from .route_manifest import (
) )
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues
from .selection import protocols_for_selection, select_profile, select_protocols from .selection import protocols_for_selection, select_profile, select_protocols
from .state_contract import StatePathContract, state_contract
from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status
@@ -175,6 +176,11 @@ def main(argv: list[str] | None = None) -> int:
default="manual", default="manual",
help="Source label stored with the captured address.", help="Source label stored with the captured address.",
) )
state_contract_parser = subcommands.add_parser(
"state-contract",
help="Print AMPG-owned state paths and daemon-written files.",
)
_add_target_selection(state_contract_parser)
preview_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.") preview_parser = subcommands.add_parser("preview", help="Preview generated outputs locally.")
preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True) preview_subcommands = preview_parser.add_subparsers(dest="preview_command", required=True)
for preview_command in ("endpoints", "manifest", "serve"): for preview_command in ("endpoints", "manifest", "serve"):
@@ -269,6 +275,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_manifest(config) return _cmd_manifest(config)
if args.command == "addresses": if args.command == "addresses":
return _cmd_addresses(config, args) return _cmd_addresses(config, args)
if args.command == "state-contract":
return _cmd_state_contract(config)
if args.command == "preview": if args.command == "preview":
return _cmd_preview(config, args) return _cmd_preview(config, args)
if args.command == "routes": if args.command == "routes":
@@ -623,6 +631,34 @@ def _cmd_addresses(config, args) -> int:
return 1 return 1
def _cmd_state_contract(config) -> int:
contracts = state_contract(config)
for contract in contracts:
_print_state_contract(contract)
print(
"AMPG_STATE_SUMMARY "
f"sites={len(config.sites)} "
f"entries={len(contracts)} "
f"required={sum(1 for contract in contracts if contract.required)} "
f"sensitive={sum(1 for contract in contracts if contract.sensitive)}"
)
return 0
def _print_state_contract(contract: StatePathContract) -> None:
print(
"AMPG_STATE "
f"site={contract.site_id} "
f"protocol={contract.protocol} "
f"role={contract.role} "
f"owner={contract.owner} "
f"required={_bool(contract.required)} "
f"sensitive={_bool(contract.sensitive)} "
f"path=\"{_quote(str(contract.path))}\" "
f"description=\"{_quote(contract.description)}\""
)
def _print_address(record: AddressRecord) -> None: def _print_address(record: AddressRecord) -> None:
print( print(
"AMPG_ADDRESS " "AMPG_ADDRESS "
+3 -3
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from .config import GatewayConfig, ProtocolConfig, SiteConfig from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .platforms import PlatformProvider, detect_platform from .platforms import PlatformProvider, detect_platform
from .state_contract import daemon_config_path, protocol_state_dir
from .status import DaemonProbeFunc, TransportStatus, gateway_status from .status import DaemonProbeFunc, TransportStatus, gateway_status
from .transports import fallback_transport_adapter, transport_adapter from .transports import fallback_transport_adapter, transport_adapter
@@ -182,7 +183,7 @@ def _protocol_install_steps(
provider, provider,
stage="config", stage="config",
action="write-managed-config", action="write-managed-config",
target=str(_state_dir(config, provider, site, protocol) / "config"), target=str(daemon_config_path(config, site, protocol)),
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",
@@ -356,8 +357,7 @@ def _state_dir(
site: SiteConfig, site: SiteConfig,
protocol: ProtocolConfig, protocol: ProtocolConfig,
) -> Path: ) -> Path:
root = config.paths.state_dir if config.paths else provider.state_root return protocol_state_dir(config, site, protocol)
return root / site.id / protocol.name
def _artifact_dir( def _artifact_dir(
+389
View File
@@ -0,0 +1,389 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .config import GatewayConfig, ProtocolConfig, SiteConfig
@dataclass(frozen=True)
class StatePathContract:
site_id: str
protocol: str
role: str
path: Path
owner: str
required: bool
sensitive: bool
description: str
def gateway_state_root(config: GatewayConfig) -> Path:
if config.paths:
return config.paths.state_dir
return config.config_path.parent / ".ampg/state"
def protocol_state_dir(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> Path:
return gateway_state_root(config) / site.id / protocol.name
def state_contract(config: GatewayConfig) -> list[StatePathContract]:
contracts: list[StatePathContract] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
contracts.extend(protocol_state_contract(config, site, protocol))
return contracts
def protocol_state_contract(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> list[StatePathContract]:
state_dir = protocol_state_dir(config, site, protocol)
contracts = [
_contract(
site,
protocol,
role="state-dir",
path=state_dir,
owner="ampg",
required=True,
sensitive=False,
description="AMPG-owned protocol state directory.",
)
]
if protocol.name == "tor":
contracts.extend(_tor_contract(site, protocol, state_dir))
elif protocol.name == "i2p":
contracts.extend(_i2p_contract(site, protocol, state_dir))
elif protocol.name == "gemini":
contracts.extend(_gemini_contract(site, protocol, state_dir))
elif protocol.name == "clearnet":
contracts.extend(_clearnet_contract(site, protocol, state_dir))
else:
contracts.extend(_generic_contract(site, protocol, state_dir))
return contracts
def address_capture_candidates(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> tuple[Path, ...]:
candidates: list[Path] = []
configured_file = protocol.options.get("address_file")
if configured_file:
path = Path(str(configured_file)).expanduser()
if not path.is_absolute():
path = config.config_path.parent / path
candidates.append(path.resolve())
candidates.extend(
contract.path
for contract in protocol_state_contract(config, site, protocol)
if contract.role == "address-file"
)
return tuple(candidates)
def daemon_config_path(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> Path:
state_dir = protocol_state_dir(config, site, protocol)
if protocol.name == "tor":
return state_dir / "torrc"
if protocol.name == "i2p":
return state_dir / "i2pd-tunnels.conf"
if protocol.name == "clearnet":
return state_dir / "nginx-server.conf"
if protocol.name == "gemini":
return state_dir / "agate-plan.txt"
return state_dir / f"{protocol.daemon}-plan.txt"
def _tor_contract(
site: SiteConfig,
protocol: ProtocolConfig,
state_dir: Path,
) -> list[StatePathContract]:
return [
_contract(
site,
protocol,
role="daemon-config",
path=state_dir / "torrc",
owner="ampg",
required=True,
sensitive=False,
description="Reviewed Tor config copied into managed state before start.",
),
_contract(
site,
protocol,
role="daemon-data",
path=state_dir / "data",
owner="daemon",
required=True,
sensitive=False,
description="Tor DataDirectory.",
),
_contract(
site,
protocol,
role="identity-dir",
path=state_dir / "hidden-service",
owner="daemon",
required=True,
sensitive=True,
description="Tor onion-service key material and hostname.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "hidden-service/hostname",
owner="daemon",
required=True,
sensitive=False,
description="Onion hostname captured after Tor creates the hidden service.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "hostname",
owner="operator",
required=False,
sensitive=False,
description="Optional adopted-daemon hostname export.",
),
_contract(
site,
protocol,
role="log-file",
path=state_dir / "tor.log",
owner="daemon",
required=False,
sensitive=False,
description="Managed Tor log file.",
),
]
def _i2p_contract(
site: SiteConfig,
protocol: ProtocolConfig,
state_dir: Path,
) -> list[StatePathContract]:
key_file = str(protocol.options.get("keys_file", f"{site.id}-web.dat"))
return [
_contract(
site,
protocol,
role="daemon-config",
path=state_dir / "i2pd-tunnels.conf",
owner="ampg",
required=True,
sensitive=False,
description="Reviewed i2pd tunnel config copied into managed state before start.",
),
_contract(
site,
protocol,
role="daemon-data",
path=state_dir / "data",
owner="daemon",
required=True,
sensitive=False,
description="Managed i2pd data directory.",
),
_contract(
site,
protocol,
role="identity-key",
path=state_dir / key_file,
owner="daemon",
required=True,
sensitive=True,
description="I2P destination key file for the web tunnel.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "hostname.txt",
owner="daemon",
required=True,
sensitive=False,
description="Preferred I2P hostname or b32 export captured after tunnel creation.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "b32.txt",
owner="daemon",
required=False,
sensitive=False,
description="Alternate I2P b32 export captured after tunnel creation.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "address.txt",
owner="operator",
required=False,
sensitive=False,
description="Optional adopted-daemon address export.",
),
]
def _gemini_contract(
site: SiteConfig,
protocol: ProtocolConfig,
state_dir: Path,
) -> list[StatePathContract]:
return [
_contract(
site,
protocol,
role="daemon-config",
path=state_dir / "agate-plan.txt",
owner="ampg",
required=True,
sensitive=False,
description="Reviewed Agate launch values copied into managed state before start.",
),
_contract(
site,
protocol,
role="certificate",
path=state_dir / f"{site.id}.crt",
owner="ampg",
required=True,
sensitive=False,
description="Gemini certificate.",
),
_contract(
site,
protocol,
role="identity-key",
path=state_dir / f"{site.id}.key",
owner="ampg",
required=True,
sensitive=True,
description="Gemini private key.",
),
]
def _clearnet_contract(
site: SiteConfig,
protocol: ProtocolConfig,
state_dir: Path,
) -> list[StatePathContract]:
return [
_contract(
site,
protocol,
role="daemon-config",
path=state_dir / "nginx-server.conf",
owner="ampg",
required=True,
sensitive=False,
description="Reviewed nginx config copied into managed state before start.",
),
_contract(
site,
protocol,
role="pid-file",
path=state_dir / "nginx.pid",
owner="daemon",
required=False,
sensitive=False,
description="Managed nginx pid file.",
),
_contract(
site,
protocol,
role="log-file",
path=state_dir / "nginx-error.log",
owner="daemon",
required=False,
sensitive=False,
description="Managed nginx error log.",
),
]
def _generic_contract(
site: SiteConfig,
protocol: ProtocolConfig,
state_dir: Path,
) -> list[StatePathContract]:
return [
_contract(
site,
protocol,
role="daemon-config",
path=state_dir / f"{protocol.daemon}-plan.txt",
owner="ampg",
required=False,
sensitive=False,
description="Adapter-specific reviewed daemon config.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "address.txt",
owner="daemon",
required=False,
sensitive=False,
description="Adapter-specific public address export.",
),
_contract(
site,
protocol,
role="address-file",
path=state_dir / "hostname",
owner="daemon",
required=False,
sensitive=False,
description="Adapter-specific public hostname export.",
),
]
def _contract(
site: SiteConfig,
protocol: ProtocolConfig,
*,
role: str,
path: Path,
owner: str,
required: bool,
sensitive: bool,
description: str,
) -> StatePathContract:
return StatePathContract(
site_id=site.id,
protocol=protocol.name,
role=role,
path=path,
owner=owner,
required=required,
sensitive=sensitive,
description=description,
)
+4
View File
@@ -41,6 +41,7 @@ python3 -m ampg --config gateway.toml status
python3 -m ampg --config gateway.toml doctor python3 -m ampg --config gateway.toml doctor
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p
python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p --write-artifacts python3 -m ampg --config gateway.toml install-plan --profile mobile-i2p --write-artifacts
python3 -m ampg --config gateway.toml state-contract --profile mobile-i2p
python3 -m ampg --config gateway.toml addresses list --profile mobile-i2p python3 -m ampg --config gateway.toml addresses list --profile mobile-i2p
python3 -m ampg --config gateway.toml addresses capture --profile mobile-i2p python3 -m ampg --config gateway.toml addresses capture --profile mobile-i2p
python3 -m ampg --config gateway.toml health-plan --profile mobile-i2p python3 -m ampg --config gateway.toml health-plan --profile mobile-i2p
@@ -69,6 +70,9 @@ Tor/I2P HTTP publishing, AMPG writes both transport-daemon artifacts and loopbac
artifacts. The generated daemon configs store runtime state, identity keys, logs, and artifacts. The generated daemon configs store runtime state, identity keys, logs, and
daemon-written addresses under `gateway.state_dir`. daemon-written addresses under `gateway.state_dir`.
`state-contract` prints the resolved managed-state paths for enabled protocols, including
which paths are AMPG-owned, daemon-written, required, or sensitive.
`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
address files from AMPG state or a protocol `address_file` override, then updates the address files from AMPG state or a protocol `address_file` override, then updates the
+42
View File
@@ -0,0 +1,42 @@
# State Contract
> Status: draft | Updated 2026-07-06 | Applies to: managed daemon state
AMPG keeps managed daemon state under `gateway.state_dir`. The state contract describes
which files AMPG owns, which files daemons or adapters write, which paths may contain
private keys, and which public address files `addresses capture` reads.
## Command
```sh
python3 -m ampg --config gateway.toml state-contract
python3 -m ampg --config gateway.toml state-contract --profile mobile-i2p
python3 -m ampg --config gateway.toml state-contract --protocol tor
```
Each `AMPG_STATE` row includes:
- `role`: state directory, daemon config, identity key, address file, log file, or similar.
- `owner`: `ampg`, `daemon`, or `operator`.
- `required`: whether a managed deployment expects the path.
- `sensitive`: whether the path may contain private identity material.
- `path`: resolved path under `gateway.state_dir` unless an adapter documents otherwise.
## Address Capture
`addresses capture` uses the same contract. For each enabled protocol it checks explicit
`address_file` config first, then contract `address-file` entries in order.
Current managed defaults:
| Protocol | Preferred captured address file | Sensitive identity path |
| --- | --- | --- |
| Tor | `<state_dir>/<site>/tor/hidden-service/hostname` | `<state_dir>/<site>/tor/hidden-service` |
| I2P | `<state_dir>/<site>/i2p/hostname.txt` | `<state_dir>/<site>/i2p/<keys_file>` |
| Reticulum/IPFS/custom | `<state_dir>/<site>/<protocol>/address.txt` | adapter-specific |
## Safety
AMPG must never delete sensitive contract paths without an explicit destructive command.
Live apply should copy reviewed config artifacts into their contract `daemon-config`
paths, start daemons, capture public addresses, and then run published health checks.
+136
View File
@@ -0,0 +1,136 @@
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from ampg.addresses import capture_addresses
from ampg.cli import main
from ampg.config import load_config
from ampg.state_contract import address_capture_candidates, state_contract
def _write_config(root: Path, protocol_block: str) -> Path:
source = root / "site"
source.mkdir()
(source / "index.html").write_text("<h1>Hello</h1>", encoding="utf-8")
config_path = root / "gateway.toml"
config_path.write_text(
f"""
[gateway]
state_dir = "./state"
cache_dir = "./cache"
run_dir = "./run"
[profiles.mobile-i2p]
protocols = ["i2p"]
[[site]]
id = "example"
domain = "example.test"
[site.source]
kind = "static-html"
path = "./site"
[site.outputs]
root = "./out"
plan_root = "./plan"
{protocol_block}
""",
encoding="utf-8",
)
return config_path
class StateContractTest(unittest.TestCase):
def test_i2p_contract_declares_config_key_and_address_files(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config = load_config(
_write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
keys_file = "example-web.dat"
""",
)
)
contracts = state_contract(config)
by_role_path = {(contract.role, contract.path) for contract in contracts}
self.assertIn(("daemon-config", root / "state/example/i2p/i2pd-tunnels.conf"), by_role_path)
self.assertIn(("identity-key", root / "state/example/i2p/example-web.dat"), by_role_path)
self.assertIn(("address-file", root / "state/example/i2p/hostname.txt"), by_role_path)
sensitive = {contract.role for contract in contracts if contract.sensitive}
self.assertEqual({"identity-key"}, sensitive)
def test_address_capture_uses_contract_candidates(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config = load_config(
_write_config(
root,
"""
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
""",
)
)
site = config.sites[0]
protocol = site.protocols["i2p"]
candidates = address_capture_candidates(config, site, protocol)
address_file = root / "state/example/i2p/hostname.txt"
address_file.parent.mkdir(parents=True)
address_file.write_text("contractcapture.b32.i2p\n", encoding="utf-8")
results = capture_addresses(config)
self.assertEqual(address_file, candidates[0])
self.assertEqual("captured", results[0].status)
self.assertEqual(address_file, results[0].path)
self.assertEqual("http://contractcapture.b32.i2p/", results[0].url)
def test_state_contract_cli_respects_profile_scope(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp).resolve()
config_path = _write_config(
root,
"""
[site.protocols.clearnet]
enabled = true
renderer = "clearnet"
[site.protocols.i2p]
enabled = true
renderer = "privacy-html"
""",
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
status = main(
[
"--config",
str(config_path),
"state-contract",
"--profile",
"mobile-i2p",
]
)
output = stdout.getvalue()
self.assertEqual(0, status)
self.assertIn("AMPG_STATE site=example protocol=i2p role=address-file", output)
self.assertIn("sensitive=true", output)
self.assertIn("AMPG_STATE_SUMMARY", output)
self.assertNotIn("protocol=clearnet", output)
if __name__ == "__main__":
unittest.main()