Add the discovery HTTP API: list, lease, release, report over the manager cache

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-09 15:26:11 +02:00
parent f6d50e4744
commit 4aa3d47e3c
6 changed files with 950 additions and 8 deletions

View File

@@ -1,12 +1,10 @@
# Architecture
> **Status:** the operator is built through Step 6 (lease store) of
> **Status:** the operator is built through Step 7 (discovery API) of
> [docs/plans/2026-08-07-1747-proxy-operator.md](plans/2026-08-07-1747-proxy-operator.md).
> This document currently covers the event/reconcile flow; the components
> table and the Decisions section arrive with Step 10. The lease store
> (`internal/lease/`) is HTTP-driven, not cluster-event-driven, so its
> diagram lands together with the discovery API in Step 7; the orphan-GC
> flow lands with Step 9.
> This document currently covers the event/reconcile flow and the
> HTTP-driven lease/discovery path; the components table and the Decisions
> section arrive with Step 10, and the orphan-GC flow lands with Step 9.
## Event flow: cluster events → reconciler functions
@@ -185,3 +183,48 @@ Consequence worth knowing: `status.lastHealthCheckTime` is the time of the
last *status-affecting* probe, not the most recent probe — suppressed
probes deliberately never write status. True probe recency will live in
metrics (Step 9).
### 7. Discovery + lease API (`internal/discovery/`, `internal/lease/`)
HTTP-driven, not cluster-event-driven: crawler clients call in; the only
Kubernetes interaction is reading Proxies from the manager's cache. The
server is a non-leader-elected Runnable (all replicas would serve, but the
deployment ships `replicas: 1` because lease state is per-process — an
operator restart drops all leases and cooldowns, a documented caveat).
```text
crawler client
│ Authorization: Bearer $DISCOVERY_TOKEN (empty token = auth disabled, loud startup warning)
Server.handler() middleware, outermost first (server.go)
recover → request-log → MaxBytesReader(64KiB) → bearer auth (constant-time; /healthz exempt)
├─ GET /healthz ──► 200 ok (unauthenticated)
├─ GET /v1/proxies?attr.k=v&healthy=true (handlers.go)
│ Reader.List(Proxies) ── manager cache
│ filter: attributes equality + Healthy condition
│ + Store.Counts() for activeLeases
│ ──► 200 {"proxies":[...], "count":N} (empty list is 200, not 404)
├─ POST /v1/leases {"selector":{...},"ttlSeconds":300,"target":"..."}
│ Reader.List → filter selector; unhealthy matches counted, not offered
│ Store.Acquire(healthy candidates, target, ttl) ── one lock: select+insert
│ │ selection: fewest active leases, then latency, then name
│ ├─ granted ──► 201 {leaseID, proxy:{...}, expiresAt, ttlSeconds}
│ └─ ErrNoMatch ──► 409 {"error":"no_match", considered, atCapacity,
│ inCooldown, unhealthy}
├─ DELETE /v1/leases/{id} ──► Store.Release ──► always 204 (idempotent)
└─ POST /v1/leases/{id}/report {"result":"ok|rate_limited|banned","target":"..."}
Store.Report ── rate_limited/banned ⇒ cooldown[{proxy,target}] for
│ CooldownWindow (target falls back: report → lease → global)
├─ 204 │ 400 invalid_result │ 404 unknown_lease
└─ an expired lease still resolves for CooldownWindow past its TTL —
a late report lands exactly when the proxy is being rate-limited
Store.Start(ctx) ── manager Runnable, NOT leader-elected: sweeps expired
leases + cooldowns; correctness never depends on the
sweep (every read checks ExpiresAt against the clock)
```