Files
egress-proxies-operator/docs/plans/2026-08-07-1747-proxy-operator.md

564 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: proxy-operator — Kubernetes operator for crawling-proxy VMs
**Created:** 2026-08-07 17:47
## Context
The crawling department runs a small fleet (tens) of HTTP proxy VMs across cloud
providers to dodge rate limiting. This builds a production-quality **prototype**
operator making each proxy VM a first-class Kubernetes object: GitOps-managed,
actively health-checked *through the proxy*, and discoverable by crawler clients via
an HTTP list/lease API.
The repo is empty — `go.mod`, `CLAUDE.md`, `CHANGELOG.md`, `.claude/`, and the spec at
[docs/prompts/__initial-prompt.md](../prompts/__initial-prompt.md). Zero commits.
All greenfield. Governing principle: **proxies are immutable cattle** — any meaningful
spec change deletes and recreates the VM. No in-place update logic.
### Decisions locked with the user
| Question | Decision |
|---|---|
| Module path | Keep `gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator` (spec's `github.com/CHANGEME/...` was a placeholder) |
| API group | `crawl.example.com`, `v1alpha1`, kind `Proxy`, **namespaced** |
| Git flow | Scaffold commit, then work on `feat/proxy-operator`, MR via `tea`, no merge/delete from CLI |
| kubebuilder | `go install sigs.k8s.io/kubebuilder/v4/cmd/kubebuilder@v4.15.0` |
| Local/CI provider | **Kubernetes pod provider**, not an in-memory mock: creates real `ubuntu/squid` pods in-cluster. Revised mid-build — see Step 3 |
| Verification | vet + unit + envtest, then a throwaway kind cluster running the README quickstart, then delete it |
### Verified environment — no version substitutions needed
Go 1.26.4 · kubebuilder v4.15.0 · controller-runtime v0.24.1 · k8s.io/* v0.36.3 ·
controller-tools v0.21.0 · compute v1.65.0 · kind v0.32.0 · kubectl v1.36.1 · docker
29.6.2 · gcloud present. envtest bundles exist for k8s **1.36.0 and 1.36.2** on
darwin/arm64 — the scaffold Makefile derives the *minor* (`1.36`) from `k8s.io/api`
and resolves the latest patch; do not "fix" it to 1.36.3, which has no bundle.
Every pin in the spec is satisfiable. The README will say so explicitly rather than
omitting the substitutions section.
### Milestone order — each ends green on `go build ./... && go vet ./...`
1. Scaffold + pins → 2. `api/v1alpha1` + CEL → 3. `internal/provider` + kubernetes-pod →
4. reconciler + envtest → 5. health engine → 6. lease + discovery → 7. GCP provider →
8. orphan GC + metrics → 9. `cmd/main.go` wiring + `config/` → 10. docs + kind run.
---
## Step 0 — Branch and scaffold
```bash
git checkout -b feat/proxy-operator # main is unborn; branch starts empty
go install sigs.k8s.io/kubebuilder/v4/cmd/kubebuilder@v4.15.0
kubebuilder init --domain example.com \
--repo gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator --plugins go/v4
kubebuilder create api --group crawl --version v1alpha1 --kind Proxy --resource --controller
make manifests generate
```
**Trap:** `--domain crawl.example.com --group crawl` yields group
`crawl.crawl.example.com`. It must be `--domain example.com --group crawl`.
If `init` refuses the non-empty directory, scaffold into an empty temp dir with the
identical `--repo` and copy the tree in — don't fight the emptiness check. Copy this
plan to `docs/plans/2026-08-07-1747-proxy-operator.md` per CLAUDE.md. **Commit the
untouched scaffold on its own** so every later diff is reviewable.
Post-scaffold hand-edits: `CONTROLLER_TOOLS_VERSION ?= v0.21.0` in the Makefile (CEL
emission at the 1.36 API level); add a `run-dev` target wired to a sample
`--providers-config` (Step 10); delete the scaffolded `.github/workflows/` (the
remote is Gitea).
---
## Step 1 — API types (`api/v1alpha1/proxy_types.go`)
Structs exactly as the spec dictates, with these **four corrections that are silent
bugs otherwise**:
- **`MaxLeases *int32`**, not `int32`. With a value type + `omitempty` + `default=5`,
an explicit `0` is dropped on any Go round-trip and re-defaulted to 5 — "0 =
unleasable" becomes unreachable.
- **`HealthCheck *HealthCheckSpec` needs `+kubebuilder:default={}`.** Structural
defaulting only descends into values that exist; without it a nil `healthCheck` gets
*none* of its nested defaults and the spec's "all defaulted" quietly fails.
- `+kubebuilder:validation:MinLength=1` on `Provider` and `CloudInit.Inline`, so an
explicit `""` fails OpenAPI validation and the CEL `has()` rules stay simple.
- Conditions get `+listType=map +listMapKey=type`.
**CEL at the `ProxySpec` struct level** — cross-field rules cannot live on a field:
```go
// +kubebuilder:validation:XValidation:rule="self.mode == oldSelf.mode",message="mode is immutable"
// +kubebuilder:validation:XValidation:rule="has(self.provider) == has(oldSelf.provider) && (!has(self.provider) || self.provider == oldSelf.provider)",message="provider is immutable"
// +kubebuilder:validation:XValidation:rule="self.mode != 'Managed' || has(self.provider)",message="provider is required when mode is Managed"
// +kubebuilder:validation:XValidation:rule="self.mode != 'External' || !has(self.provider)",message="provider must not be set when mode is External"
// +kubebuilder:validation:XValidation:rule="self.mode != 'External' || has(self.endpoint)",message="endpoint is required when mode is External"
// +kubebuilder:validation:XValidation:rule="self.mode != 'Managed' || !has(self.endpoint)",message="endpoint must not be set when mode is Managed"
```
plus on `CloudInitSpec`:
`rule="has(self.inline) != has(self.secretRef)"` (CEL `!=` on booleans is XOR).
Semantics that matter: a rule mentioning `oldSelf` is **skipped on CREATE**, so
immutability and required-iff must be *separate markers*`&&`-ing them together
would skip the required-iff check on create. The immutability rule uses the
`has(self.x) == has(oldSelf.x) && ...` form because `Provider` is optional and a
field-level rule wouldn't fire when the field is absent on either side.
Defaults: `port=3128`, `maxLeases=5`, probeURL `https://www.gstatic.com/generate_204`,
interval 30s, timeout 5s, failureThreshold 3, successThreshold 1,
expectedStatusCodes `{200,204}`. Printer columns Mode/Provider/Phase/IP/Healthy/Age,
`shortName=px`, `+kubebuilder:subresource:status`.
Pure helpers in `helpers.go` (unit-tested): `EffectivePort()`, `EffectiveHost()`,
`HealthCheckOrDefault()`, `MaxLeasesOrDefault()`. **Port lives in two places**
(`spec.port` for Managed, `spec.endpoint.port` for External) — one helper, used by
health, discovery, and the hash.
---
## Step 2 — Provider contract (`internal/provider/`)
```
internal/provider/{provider,errors,name,config,metrics}.go
internal/provider/registry/registry.go # type→constructor — SEPARATE package
internal/provider/{kubernetes,gcp}/
```
**Import-cycle trap:** a registry inside `internal/provider` would have to import
`internal/provider/kubernetes` (or `.../gcp`), both of which import `internal/provider`
for the interface. `init()` self-registration is banned by CLAUDE.md, so the registry
goes in its own leaf-importing package, and its `Build` function takes the
type→constructor map as a parameter instead — see Step 2's execution log entry for why
this ended up better than a package-level map even beyond avoiding the cycle.
`Instance` needs **two fields the spec omits**, or orphan GC is unimplementable:
`UID string` (from the label, for the liveness match) and `CreatedAt time.Time` (for
the "skip < 10 min" rule).
**Error taxonomy** — the key trick is multi-value unwrap:
```go
type Error struct{ Class error; Op, Provider, ID string; Err error }
func (e *Error) Unwrap() []error { return []error{e.Class, e.Err} }
func Class(err error) error // returns the sentinel; unclassified → ErrTransient
```
So `errors.Is(err, ErrQuotaExceeded)` **and** `errors.As(err, &googleapiErr)` both work
on the same value. `Class()` defaulting to `ErrTransient` matters: retrying is always
safer than latching `Failed`.
**Deterministic naming** — SHA-256, first 10 bytes, RFC 4648 base32 lowercased,
unpadded → 16 chars, `proxy-` + that = 22 total:
```go
func NameFromUID(uid types.UID) string
```
GCP requires `^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$` ≤63. base32 lowercases to `[a-z2-7]`
— all legal; base64's `+`/`/`/uppercase are not, and hex would need 20 chars for the
same 80 bits. 80 bits → birthday collision at ~2^40 objects against a fleet of tens.
22 chars leaves headroom because GCP auto-names the boot disk after the instance.
`config.go` parses the YAML with `yaml.UnmarshalStrict` and validates at load
(non-empty + unique names, known type, type block present, gcp requires `project`).
**Fail fast from `main`** — never degrade.
---
## Step 3 — Kubernetes pod provider (`internal/provider/kubernetes/`)
**Revised after Step 3 was first built as an in-memory mock provider** (state-machine
simulation + a hand-rolled CONNECT proxy on a shared, refcounted local listener). The
user found that too far from the real system to build confidence in, and didn't need
tests to be fast enough to justify the complexity it cost — a real `kind`-cluster
verification pass "once in a while" is an acceptable trade for tests that actually
look like the final product. Full narrative of the reversal is in
`docs/plans-executions/2026-08-07-1747-proxy-operator.md`; this section describes the
replacement, which is what actually gets built for local dev/CI going forward. No
in-memory provider remains in the tree — GCP is now the only other provider, per the
spec's original two-provider scope.
**What it does:** `Create` creates a `corev1.Pod` running a proxy container in the
same cluster (and same namespace as the owning Proxy CR — `req.Namespace`); `Get`
reads the Pod's phase/IP; `Delete` deletes it (tolerating NotFound); `ListByTag` lists
Pods by the standard `LabelManaged`/`LabelUID` labels, unscoped by namespace (the
operator's RBAC needs cluster-scoped Pod permissions — see RBAC note below).
**Proxy software: `ubuntu/squid`** (Canonical's actively maintained LTS image on
Docker Hub, verified before picking it — 50M+ pulls, updated the same day this
decision was made), not a hand-rolled proxy. It's a public image, so `kind` nodes
pull it directly; no build/load step needed for the quickstart. Squid's config
(`http_port <req.Port>`, permissive ACL) is generated in Go and injected via an env
var the container's command writes to `/etc/squid/squid.conf` before exec'ing squid —
no separate ConfigMap object, so there's still only one Kubernetes object per proxy
instance to create, track, and clean up.
**providerID format: `<namespace>/<podName>`** (parseable with
`k8s.io/client-go/tools/cache.SplitMetaNamespaceKey`), so `Get`/`Delete` are
self-contained without needing to re-derive the namespace — the same reasoning as the
GCP provider's zone-qualified providerID in Step 8.
**Pod naming:** reuses `provider.NameFromUID` unchanged — the same deterministic name
satisfies Kubernetes Pod naming rules (`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`, ≤253 chars)
with room to spare.
**State mapping:** Pod phase `Pending`, or `Running` with no PodIP yet → Provisioning
(never publish an empty IP); `Running` with a PodIP → Running; `Succeeded`/`Failed`/
`Unknown` → Terminated (the reconciler treats Stopped and Terminated identically —
delete and recreate, cattle not pets — so collapsing three failure-ish phases into one
is enough).
**Client:** built internally via `ctrl.GetConfig()` (auto-detects in-cluster config,
falls back to the local kubeconfig otherwise), not threaded through the registry
`Constructor` signature — this is what makes `make run` against a local `kind`
cluster and running in-cluster use the exact same code path with no provider-specific
wiring in `cmd/main.go`.
**Testing, given envtest can't schedule real Pods** (no kubelet — a Pod created
against `envtest`'s API server just sits `Pending` forever): `internal/provider/kubernetes`
itself is unit-tested against `sigs.k8s.io/controller-runtime/pkg/client/fake` — real
Pod objects, real client interface, fully exercises Create/Get/Delete/ListByTag
logic and the Pod-construction function in isolation, just without a kubelet actually
starting a container. Real end-to-end proof (does a probe actually tunnel through a
real Squid pod) only happens against a real `kind` cluster, in the Verification
section — which is exactly what the user asked for. Step 4's reconciler tests use a
small `Provider`-interface stub defined directly in the controller test file (a
handful of lines, not a package) for exercising the state-machine's branching logic —
categorically simpler than what mock.Provider was, since it has no config format, no
fault-injection surface, and exists only inside test code.
---
## Step 4 — Reconciler (`internal/controller/`)
A **state machine**: every reconcile derives one action from (spec, status, provider
`Get`). Intervals are struct fields, never consts, so envtest can shrink them to
milliseconds.
```go
func (r *ProxyReconciler) Reconcile(ctx, req) (res ctrl.Result, err error) {
// Get; base := p.DeepCopy()
// defer patchStatusIfChanged(ctx, base, &p) // one status write per reconcile, max
switch {
case !p.DeletionTimestamp.IsZero(): return r.reconcileDelete(ctx, &p)
case p.Spec.Mode == v1alpha1.ModeExternal: return r.reconcileExternal(ctx, &p)
default: return r.reconcileManaged(ctx, &p)
}
}
```
`patchStatusIfChanged` sets `observedGeneration` and `phase = computePhase(&p)`, then
issues nothing when `equality.Semantic.DeepEqual(base.Status, p.Status)`. `computePhase`
is pure and is the primary table-driven unit-test target.
### Action table (Managed) — `A` = hash annotation, `H` = computed hash
| deletionTS | ID | A vs H | provider.Get | action | result |
|---|---|---|---|---|---|
| no | — | — | — | finalizer absent → add it | `{}` (the Update re-triggers) |
| no | `""` | any | — | resolve cloud-init, `Create`, set ID + `A=H` | `RequeueAfter: ProvisioningPoll` |
| no | set | `==` | Provisioning | clear `ip`, Provisioned=False/Provisioning | `RequeueAfter: ProvisioningPoll` |
| no | set | `==` | Running | set `ip`, Provisioned=True/Created | `RequeueAfter: DriftPoll` |
| no | set | `==` | Stopped/Terminated | `Delete(ID)` — cattle, not pets | `RequeueAfter: DeletionPoll` |
| no | set | `==` | NotFound | clear ID+ip → next pass creates | `Requeue: true` |
| no | set | `!=`, `A != ""` | any | **replace**: `Delete(ID)`, Provisioned=False/Replacing | `RequeueAfter: DeletionPoll` |
| no | set | `!=`, `A == ""` | any | **adopt**: set `A=H`, no replacement | `Requeue: true` |
| no | set | `!=` | NotFound | clear ID+ip (status), then set `A=H` (metadata) | `Requeue: true` |
| yes | `""` | — | — | remove finalizer (orphan GC reaps any stray VM) | `{}` |
| yes | set | — | NotFound | remove finalizer | `{}` |
| yes | set | — | anything else | `Delete(ID)`, phase=Deleting | `RequeueAfter: DeletionPoll` |
External: no finalizer, no provider calls, `status.ip = spec.endpoint.host`,
Provisioned=True/ExternalEndpoint, health drives the rest.
**The trap the spec doesn't mention:** the instance name derives from the *CR UID*,
which does **not** change on a spec edit — so the replacement VM has the *same* name
as the one being deleted. Recreating immediately hits `409 alreadyExists` against a
still-deleting instance. Hence replacement **polls to `NotFound` before recreating**
(rows 7 → 9 → 2). Do not add the hash to the instance name; that breaks the spec's
naming contract and only buys blue/green, a non-goal.
**Crash-safety:** ID is never lost destructively. Even if status is wiped entirely, the
create branch calls `Create`, which finds the existing VM by deterministic name and
returns its ID. That's what makes deterministic naming load-bearing rather than
cosmetic.
**Adopt-on-empty-annotation is required** — otherwise the first deploy of an operator
version whose hash-input struct gained a field mass-replaces the whole fleet.
**Spec hash:** SHA-256 over canonical JSON of an explicit `{placement, cloudInit
(resolved content), port}` struct — explicit, not `ProxySpec` wholesale, to bound
upgrade churn. Because it covers *resolved* Secret content, rotating the Secret must
re-trigger: `Watches(&corev1.Secret{}, EnqueueRequestsFromMapFunc(proxiesForSecret))`
with the cache restricted to Secrets labelled `crawl.example.com/cloud-init=true`.
~30 lines, and it's the difference between immutable replacement working and being
silently stale.
**Requeue per class:** `ErrTransient`/unclassified → return the error (workqueue
backoff). `ErrQuotaExceeded` → condition + `RequeueAfter: 5m`, return **nil** (keeps it
off the backoff curve and out of the error log). `ErrPermanent` → phase `Failed`,
condition, return nil. `ErrNotFound` → never an error, a state-machine input.
Conditions via `apimeta.SetStatusCondition` — note it does **not** populate
`ObservedGeneration`, so pass it explicitly or every condition reports generation 0.
`MaxConcurrentReconciles: 3`.
---
## Step 5 — Health engine (`internal/health/`)
**Delivery: push transitions to the reconciler via channel + `source.Channel` (option
a).** Rationale for `docs/architecture.md` → Decisions: `status.phase` is derived from
*both* provisioning and health. Under direct-patch, two writers each compute `phase`
from half the picture and race on the same subresource — a lost-update/flapping bug.
Option (a) keeps exactly one writer of `.status`, makes "write only on transition" fall
out for free (the engine only *emits* on transition), and costs one channel plus a
read-only `Snapshot()` method. The engine owns health *state*; the reconciler owns
health *representation*.
Channel typed `event.TypedGenericEvent[client.Object]` so the untyped
`&handler.EnqueueRequestForObject{}` satisfies it. Non-blocking send with `default:`
a wedged reconciler must never stall the probe loop; on drop, don't advance the
"reported" markers, so the next probe retries.
**Threading:** one scheduler goroutine on a 1 s ticker + a fixed pool of 8 workers fed
by a buffered channel. Each tick lists from the cache and enqueues proxies whose
`nextDue <= now` and that aren't in flight. At tens of proxies a per-second list-scan is
free and a timer wheel is unjustified complexity. Startup jitter seeds
`nextDue = now + rand(0, interval)` so a restart doesn't fire every probe at once.
**Probe client** — fresh transport per probe, `defer CloseIdleConnections()`:
```go
Transport: &http.Transport{ Proxy: http.ProxyURL(proxyURL), DisableKeepAlives: true,
ForceAttemptHTTP2: false, TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout, DialContext: (&net.Dialer{Timeout: timeout}).DialContext }
```
`DisableKeepAlives: true` is **load-bearing** — otherwise `net/http` caches the
established CONNECT tunnel and later probes never re-exercise CONNECT, which is exactly
the failure the spec wants caught.
**CONNECT semantics:** for the default `https://` probe URL the transport sends
`CONNECT host:443` then TLS-handshakes through the tunnel. A proxy that accepts TCP but
can't egress returns non-200 to CONNECT, and `client.Do` returns an **error**, not a
response. So the success predicate is `err == nil && slices.Contains(expected,
resp.StatusCode)` — both halves. Latency is wall time around `Do`, last-value.
**Threshold state:** `map[NamespacedName]*state` under a mutex. Entries are pruned each
tick against the cache list (no leak), and `state.uid` is compared to the CR's UID so a
delete+recreate of the same name doesn't inherit stale fail counters. On leader
handover, state is empty: seed `healthy` from the CR's existing `Healthy` condition so a
healthy proxy doesn't flap to Unknown, but leave counters at zero so a real transition
still needs a full `failureThreshold` run. Documented as a Decision.
**Suppression:** emit only on (a) first-ever result, (b) a threshold-crossing flip, or
(c) `|new reported| > max(20ms, 0.5×reported)` **and** `now lastReported > 60s`.
The spec's bare ">50% latency bucket change" is undefined at 0 and makes a proxy
jittering 40↔61 ms write status forever; the absolute floor plus rate limit is what
actually delivers "no unbounded status churn". Flagged in Decisions. Metrics are
observed on *every* probe — that's the right home for high-frequency signal.
Note for the README: transition-only writes mean `status.lastHealthCheckTime` is stale
by construction. It means "time of the last *status-affecting* probe"; true probe
recency lives in metrics.
---
## Step 6 — Lease store (`internal/lease/`)
**`Acquire` takes the candidate *set*, not a chosen proxy** — selection and insertion
must happen under one lock, or two concurrent requests both see "3 of 5 used" and
overcommit.
```go
Acquire(ctx, AcquireRequest{Candidates []Candidate; Target string; TTL time.Duration}) (*Lease, AcquireStats, error)
Release / Report / ActiveCount / Counts / ExpireLoop
```
One `sync.Mutex` for the whole store (tens of proxies, human-rate QPS; sharding is
premature), injectable clock, `byID` + `byProxy` + `cooldown[{proxy,target}]` maps.
Selection is a linear scan + `slices.SortFunc` on `(activeLeases asc, latency asc,
name asc)` — explicitly not a heap, and the third key makes it deterministic and
testable. `AcquireStats{Considered, AtCapacity, InCooldown}` feeds the 409 body.
**Expired-lease retention:** entries stay marked `expired` for the cooldown window
after TTL. `Acquire`/`ActiveCount` ignore them; `Report` still resolves them. Without
this, a report arriving just after the TTL lapses is silently dropped — exactly when a
proxy is being rate-limited, which is when the cooldown matters most.
---
## Step 7 — Discovery API (`internal/discovery/`)
**`NeedLeaderElection() = false`**, and ship `replicas: 1`. Verified in
controller-runtime's runnable ordering: caches start and sync *before* non-leader-
election runnables, so cache reads are safe. If it *were* leader-elected, non-leader
pods would refuse connections while still being Service endpoints. The 1-replica
constraint comes from lease state being per-process, which the spec already accepts —
both facts go in the README caveats.
stdlib `http.ServeMux` using its method+wildcard patterns (`"GET /path"`,
`"/{id}"` + `r.PathValue`) — a stdlib feature available since Go 1.22, used
here so no third-party router is needed; the project itself stays on the
pinned Go 1.26:
`GET /v1/proxies`, `POST /v1/leases`, `DELETE /v1/leases/{id}`,
`POST /v1/leases/{id}/report`, plus unauthenticated `GET /healthz`.
Middleware outermost-first: recover → request-log → `MaxBytesReader(64KiB)` → bearer
auth. Empty `DISCOVERY_TOKEN` passes through **with a loud startup Warn** — in-cluster
that's a silent security hole otherwise. Token compared with
`subtle.ConstantTimeCompare`.
Shapes: list returns `{"proxies":[…],"count":N}`, empty is 200 not 404; lease grant is
201 `{leaseID, proxy, expiresAt, ttlSeconds}`; no match is 409
`{"error":"no_match","message":…,"considered":7,"atCapacity":2,"inCooldown":2,"unhealthy":3}`;
DELETE is always 204; report is 204, 400 on an unknown result value, 404 on a genuinely
unknown lease. All errors share `{"error":"<machine_code>","message":"<human>"}`.
Timeouts on `http.Server`, graceful `Shutdown` with 10 s grace on ctx cancel.
---
## Step 8 — GCP provider (`internal/provider/gcp/`)
Only `compute.NewInstancesRESTClient` (ADC), only Insert/Get/Delete/AggregatedList.
`Operation.Wait` is **never called**`Create` returns as soon as the operation is
submitted, and `409 alreadyExists` is treated as success, which is what makes a repeat
call after a crash correct.
**providerID = `zones/<zone>/instances/<name>`** — zone-qualified so `Get`/`Delete` are
self-contained. The spec's `Get(ctx, providerID)` carries no zone, and re-reading
`spec.placement.zone` is wrong precisely when a zone edit is the replacement being
processed.
**Test seam is deliberately not an SDK mirror.** `compute.InstancesScopedListPairIterator`
has an unexported `nextFunc`, so a fake cannot construct one — the interface flattens
`AggregatedList` to a slice and returns operations as just their name. The primary unit
test needs no fake at all: `buildInsertRequest` is pure, asserted field-by-field
(machine-type URL, boot disk, `AccessConfigs[0] = {Name:"External NAT",
Type:"ONE_TO_ONE_NAT"}`, `Metadata.Items[user-data]`, GC labels, network tag).
`AggregatedList` needs `ReturnPartialSuccess: true` — otherwise one unreachable zone
fails the entire GC sweep. `RUNNING` **without** a NatIP maps to `Provisioning`, not
Running, so we never publish an empty IP. Error mapping: 404→NotFound;
429 / 403+`quotaExceeded`→Quota; 400/401/403-other→Permanent; 5xx/408/net→Transient;
unknown→Transient.
---
## Step 9 — Orphan GC + metrics
**GC** (`internal/gc/`) — `NeedLeaderElection() = true` (destructive, single-writer),
10 min ticker, first sweep one interval after start. Per provider `ListByTag`; on error
log and continue to the next provider, never abort the sweep. Kill an instance only if
it has our UID label, is older than `MinAge` (10 min), and its UID matches no CR.
Log every kill at Warn with provider, providerID, UID.
**A CR with a `deletionTimestamp` still counts as live** — that's what the spec's
"check deletionTimestamp semantics carefully" points at. Its finalizer owns the
deletion; GC racing it double-deletes. A UID is orphan-eligible only once the object is
fully gone.
**Namespace-scope guard:** if the cache is namespace-restricted but Proxies exist
elsewhere, GC would delete live VMs. It refuses to start unless an explicit
`--gc-allow-namespaced` flag is set.
**Metrics** (`internal/metrics/`) registered by an explicit `Register(...)` called from
`main` (no `init()`, per house rules; also lets tests use a fresh registry).
`proxy_operator_proxies{phase}` and `proxy_operator_leases_active` are **custom
Collectors** that read at scrape time — a reconcile-incremented gauge inevitably drifts
and leaks a series on delete. Per-proxy histogram/counter labels **must** be deleted
from the vec when the health engine GCs a state entry, or series leak forever.
`proxy_operator_provider_requests_total{provider,op,result}` comes from a
`provider.WithMetrics(name, p)` decorator — zero-cost instrumentation for the next five
providers, and the one place `Class()` is called for observability.
---
## Step 10 — Wiring, config, docs
`cmd/main.go`: flags `--providers-config` (required), `--discovery-addr`,
`--proxy-namespace`, `--health-workers`, `--gc-interval`, `--gc-min-age`,
`--lease-cooldown`, `--max-lease-ttl`. Order: load provider config (fail fast) → build
registry → manager → `mgr.Add` health engine, GC, lease expiry loop, discovery server →
`SetupWithManager`. Contexts from `ctrl.SetupSignalHandler()` throughout.
RBAC markers: proxies CRUD + status + finalizers, secrets get/list/watch, events
create/patch, and (for the kubernetes-pod provider) pods get/list/watch/create/delete
— cluster-scoped, since `ListByTag` enumerates across namespaces. `config/`: providers
ConfigMap mount, `DISCOVERY_TOKEN` from a Secret, containerPort 8090 + Service.
Samples: `proxy_kubernetes.yaml`, `proxy_gcp.yaml`, `proxy_external.yaml`,
`providers-config.yaml`.
`docs/architecture.md`: components table, ASCII data-flow diagram, and a **Decisions**
section covering the channel-vs-patch choice, replacement-polls-to-NotFound, base32
naming, discovery without leader election, single-mutex lease store, the mock→
kubernetes-pod-provider revision (why, and why `ubuntu/squid` over a hand-rolled
proxy), leader-handover health seeding, the latency-suppression refinement, the
`lastHealthCheckTime` semantics, and the logr-not-slog deviation (CLAUDE.md says
`slog`, but `log.FromContext(ctx)` returns logr inside controller paths — noted, not
silently ignored).
`README.md`: 60-second architecture summary; copy-pasteable kind quickstart; GCP setup
(ADC, `roles/compute.instanceAdmin.v1`, plus `roles/iam.serviceAccountUser` if
attaching a service account); the **immutable-replacement caveat** (changing a proxy
changes its IP) and **lease-loss-on-restart caveat**, both prominent; a version-pins
note confirming no substitutions were needed. Then a `CHANGELOG.md` entry with a real
`date "+%Y-%m-%d %H:%M %Z"` timestamp.
---
## Step 11 — Tests
**envtest** (`internal/controller/`), a small in-test stub `Provider` (not the real
kubernetes-pod provider — envtest has no kubelet, so a real Pod never leaves Pending)
plus a fake `HealthSnapshotter`, intervals shrunk to 50200 ms, whole suite behind
`testing.Short()`: Managed→Ready; spec change → old stub instance gone and
providerID changed; delete → finalizer runs and instance removed; External → Ready
on first health pass, no finalizer; injected quota → `Provisioned=False/QuotaExceeded`
and phase *not* Failed; permanent error → Failed and no further provider calls; adopt
(strip annotation → restored, providerID unchanged); and the **CEL cases only a real
API server can test** — mode/provider mutation rejected, Managed-without-provider,
External-without-endpoint, cloudInit both/neither, and `healthCheck` omitted → nested
defaults materialized (the `default={}` assertion).
**Action-table unit tests** — the highest-value tests in the repo: `fake` client with
`WithStatusSubresource`, calling `Reconcile` directly, table-driven over every row of
the Step 4 table, asserting the returned `ctrl.Result`. (Documented caveat: the fake
client runs neither CEL nor defaulting — that's what the envtest CEL cases cover.)
**Units:** name derivation (idempotency, `^proxy-[a-z2-7]{16}$`, 10k-UID distinctness);
`Class()` mapping + `errors.Is`/`errors.As` through the multi-unwrap; config loading;
kubernetes-pod provider Create/Get/Delete/ListByTag against
`sigs.k8s.io/controller-runtime/pkg/client/fake` (real Pod objects, real client
interface, no kubelet needed for this level) plus pure tests of the generated Squid
config and Pod spec; `buildInsertRequest` field-by-field + GCP error classification +
RUNNING-without-IP; `computePhase` truth table; `SpecHash` stability *and*
sensitivity; lease store (capacity, `MaxLeases=0`, least-loaded with latency
tie-break, cooldown with/without target, report on an expired-but-retained lease,
concurrent acquire under `-race` never exceeding `MaxLeases`); discovery handlers
over `httptest` + fake reader + real store; health thresholds against a real
CONNECT-capable `httptest` proxy stub.
Everything runs with `-race`.
---
## Verification
```bash
go vet ./... && make test && make build # unit + envtest, -race
kind create cluster --name proxy-operator-demo
make install
make run-dev & # --providers-config hack/providers-dev.yaml
kubectl apply -f config/samples/proxy_kubernetes.yaml
kubectl get px -w # expect Ready with an IP (a real squid Pod)
curl -s 'localhost:8090/v1/proxies?healthy=true' | jq
curl -s -XPOST localhost:8090/v1/leases -d '{"selector":{"geo":"eu"},"ttlSeconds":300}' | jq
curl -s -XPOST localhost:8090/v1/leases/<id>/report -d '{"result":"rate_limited","target":"example.com"}'
curl -si -XDELETE localhost:8090/v1/leases/<id> # 204, and 204 again
kubectl delete -f config/samples/proxy_kubernetes.yaml # finalizer runs, Pod is deleted
kind delete cluster --name proxy-operator-demo
```
Success bar: a competent SRE clones the repo, follows the README, and holds a lease on a
healthy proxy — a real Squid pod running in their own `kind` cluster — in under 10
minutes.
Then commit on `feat/proxy-operator`, push with `-u`, open the MR with
`tea pr create --base main --head feat/proxy-operator`, print the URL. No merging or
branch deletion from the CLI.