Writing an adapter¶
An adapter speaks to a management plane — a hypervisor, an orchestrator, a router, a NAS, a DNS provider — and returns plain Python. Where a probe reads one host from the inside, an adapter reads a whole system from its API.
Adapters are read-only at L1. The harness proposes and never applies, so an adapter's first version exposes no method that mutates anything, however obvious the API makes it. Writes come later, if at all, and only through the executor — see the last section.
The shape¶
The OpenMediaVault adapter is a complete, small example; the pattern it follows is the one every adapter shares.
"""<Source> adapter — read-only <what it is> source.
What the source owns that nothing else can see, how it authenticates, the
configuration variables, and the note that tests inject a client.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from homelab_helper.secrets import secret_from_env
class SourceConfigError(RuntimeError):
"""Raised when required configuration is missing."""
class SourceAdapter:
def __init__(
self,
url: str,
token: str,
*,
verify_ssl: bool = True,
client: httpx.AsyncClient | None = None,
) -> None:
self._url = url.rstrip("/")
self._token = token
self._owns_client = client is None
self._client = client or httpx.AsyncClient(
base_url=self._url, verify=verify_ssl, timeout=15.0
)
@classmethod
def from_env(cls) -> SourceAdapter:
url = os.environ.get("HOMELAB_HELPER_SOURCE_URL")
token = secret_from_env("HOMELAB_HELPER_SOURCE_TOKEN")
if not url or not token:
raise SourceConfigError("HOMELAB_HELPER_SOURCE_URL and _TOKEN are required")
return cls(url, token)
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()
async def health_check(self) -> tuple[bool, str | None]: ...
async def list_things(self) -> list[dict[str, Any]]:
resp = await self._client.get("/api/things", headers=self._headers())
resp.raise_for_status()
return cast("list[dict[str, Any]]", resp.json())
Three details carry most of the weight:
The injected client and _owns_client. Tests pass an httpx.AsyncClient
built on httpx.MockTransport; the adapter must not close a client it did not
create, or the test loses its transport mid-run. Every adapter that takes a
client has this flag.
Secrets go through secret_from_env. Never os.environ.get for a token or
password. The value may be a file: / keyring: / env: reference that the
resolver dereferences, and reading through the resolver is also what registers
the value with redact(), so it is scrubbed from error strings the MCP server
returns. Non-secret settings use os.environ as usual.
Response.json() returns Any. Methods with a typed return cast it; mypy
runs in CI.
Wiring it in¶
An adapter becomes a source in four places, each small:
config.py→SOURCES. ASourceConfignaming the required and optional variables and which are secret. This is whathelper configreports as configured or not, and what the daemon uses to decide which sources to discover from by default.mcp_server.py→_DISCOVERERS. Anasync def _discover_<source>(session)that builds the adapter from the environment, reads what it reads, hands the result to a reconcile function, closes the adapter in afinally, and returns a JSON-safe summary. The MCPrun_discoverytool and the daemon both call this.cli/discover.py. Ahelper discover <source>verb with--persistand--dry-run, wrapping its coroutine in atry/finallythat disposes the engine.- A reconcile function in
engine/, if the source produces inventory or findings. Findings usemake_fingerprint(kind, target_type, target_id, root_cause_token)so the same condition always hits the same row, and resolve only when the category was observed this run — never because an observation was absent.
Then a row in the getting-started source table, and the README's.
Testing it¶
httpx.MockTransport against an injected httpx.AsyncClient, asserting on
the requests the adapter makes and feeding it the responses the real API
returns. Capture those responses from a real system once and commit them as
fixtures; the test then pins the adapter to the API's actual shape. Never hit a
live system from the suite. tests/test_netbox_adapter.py is the canonical
example; every adapter has one.
If the adapter will ever write¶
The rules change the moment an adapter gains a mutating method, and they are mechanical:
- The method is called only from
engine/executor.py(orengine/rollback.pywhen it is the inverse).tests/test_write_isolation.pygreps for every write method name across the package and fails if any other module names one; add yours to itsWRITE_METHODS. - The method carries a block comment saying it exists for the executor.
- There is an action kind for it: the manifest schema in
engine/manifest.pyand the executor's ownparse_manifest(a test holds the two in agreement), a rollback strategy inengine/rollback.pywith a read-only verifier, and the kind joinsREVERSIBLE_ACTION_KINDSonly once its inverse is itself a tested write path. A kind with no inverse takes theno-inversestrategy and can never run unattended. - A manifest for it carries the target's identity and nothing else that chooses what runs; what runs is fixed in code, or a proposal becomes remote code execution gated by one trust cell.
The CONTRIBUTING guide walks the adding-an-action-kind sequence step by step. The reason for all of it is one sentence from the trust gradient: every write to the lab goes through one gate, and an LLM is never in it.