diff --git a/docs/service-contract.md b/docs/service-contract.md index 6182732..215e5e7 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -1,33 +1,52 @@ # Service contract and architecture -## Resource +## Resource and lifecycle -A monitor has UUID `id`, `name`, absolute HTTP(S) `url`, timestamps, and `current_status`. Status is initially `unknown`; an HTTP 200–399 result is `up`, any other HTTP response is `down`, and timeout/network/security failures set `error`. Status records include check time and latency; successful HTTP exchanges also include the status code. Secrets or response bodies are never retained. +A monitor has a server-generated UUID, name, absolute HTTP(S) URL, expected HTTP status, +timestamps, and a current status. State is process-local memory guarded by one `asyncio.Lock`; +it starts empty on every process start, is not shared across workers, and is lost on restart. +Run exactly one worker unless an external store is added. Returned models are frozen so state +cannot be mutated outside the store lock. -## HTTP API +## HTTP contract -| Method/path | Result | -|---|---| -| `POST /monitors` | Create, 201 | -| `GET /monitors` | List, 200 | -| `GET /monitors/{id}` | Read, 200 | -| `PUT /monitors/{id}` | Replace name/url, 200; resets status if URL changes | -| `DELETE /monitors/{id}` | Delete, 204 | -| `POST /monitors/{id}/check` | Check now and atomically update status, 200 | -| `GET /monitors/{id}/status` | Current status, 200 | -| `GET /healthz` | Liveness, 200 | -| `GET /readyz` | Readiness, 200 after state/checker initialization | +| Method | Path | Success | Purpose | +|---|---|---:|---| +| POST | `/monitors` | 201 | Create | +| GET | `/monitors` | 200 | List | +| GET | `/monitors/{uuid}` | 200 | Retrieve | +| PATCH | `/monitors/{uuid}` | 200 | Partial update | +| DELETE | `/monitors/{uuid}` | 204 | Delete | +| POST | `/monitors/{uuid}/check` | 200 | Run and atomically persist a check | +| GET | `/monitors/{uuid}/status` | 200 | Retrieve current status | +| GET | `/health/live` | 200 | Process liveness | +| GET | `/health/ready` | 200 | In-memory service readiness | -Unknown resources return 404. Invalid requests return 422. Capacity returns 409. Unsafe targets return 400, outbound network failures 502, and timeout 504. Errors use `{"error":{"code":"...","message":"..."}}`. No authentication is provided; deploy behind an authenticated trusted gateway if exposed. +Missing resources return `{"error":{"code":"not_found","message":"monitor not found"}}` +with 404. Invalid UUIDs or bodies return a generic, input-redacting `invalid_request` with 422. +There is intentionally no authentication. -## Check security +Status meanings: `never_checked` has no observation; `up` exactly matches `expected_status`; +`down` is a completed nonmatching HTTP response; `error` is timeout/protocol/network failure; +`blocked` means outbound policy rejected the destination. Check failures are check results (200), +not API transport failures. A monitor deleted while its check runs yields 404 rather than being +recreated. -Only HTTP(S), host-bearing URLs without userinfo are accepted. A dedicated resolver rejects every non-global address from every DNS answer. Redirects are followed manually, resolved relative to the previous URL, and revalidated before each hop. The production aiohttp connector uses that same resolver, disables DNS caching, and closes connections, so connection resolution cannot bypass filtering. Total timeout and redirect count are bounded. The service does not return response bodies. +## Outbound security and logging -## Concurrency and lifecycle +Only HTTP(S), credential-free URLs are accepted by the checker. Every initial and redirected +hop is resolved immediately before request; all returned addresses must be globally routable. +Redirects are handled manually and bounded, requests have bounded timeouts, bodies are streamed, +and exceptions map to stable non-sensitive messages. This blocks direct, DNS, and redirect +attempts to loopback/private/link-local/reserved addresses. Like most application-level DNS +checks, there remains a small resolver-to-connect rebinding race; production high-assurance +deployments should additionally enforce an egress proxy/firewall. -`MonitorStore` owns one dictionary guarded by an `asyncio.Lock`. Mutations and status publication are atomic. Status publication uses the checked URL as a compare condition: a concurrent URL change cannot publish a stale result and produces a 409. Memory is per process, starts empty, and disappears on restart. A one-worker deployment is required unless the store is replaced. +Structured JSON check logs include IDs, status, latency, and scheme/host/port/path only. Query, +fragment, credentials, response bodies, and raw exception text are excluded. -## Logging and structure +## Layout -Application events are single-line JSON. Logged URLs are transformed to scheme/host/path only; credentials, query, and fragment are removed. Error text from remote systems is not logged. `main` owns routing/lifespan, `store` owns state, `checker` owns outbound security, `models` owns wire types, `config` owns environment validation, and `logging_config` owns JSON/redaction behavior. +`app/api.py` owns HTTP semantics, `models.py` schemas, `store.py` locked state, `checker.py` check +orchestration, `security.py` outbound policy, and `config.py` environment settings. Tests isolate +outbound traffic with `httpx.MockTransport`.