Files
egress-proxies-operator/docs/plans-executions/2026-08-07-1747-proxy-operator.md
Jan Novak a4a483acbc Add the provider contract, error taxonomy, naming, and config (Step 2)
The Provider interface (Create/Get/Delete/ListByTag), Instance, and
CreateRequest that every cloud backend implements — kept independent of
api/v1alpha1 so this package has no CRD-type coupling.

Error taxonomy (ErrNotFound/ErrQuotaExceeded/ErrTransient/ErrPermanent)
wrapped via a multi-error Unwrap() []error, so errors.Is and errors.As
both work off the same value: the reconciler branches on classification,
logs keep the underlying SDK error. Unclassified errors default to
ErrTransient — retrying is always safer than latching Failed.

Deterministic instance naming (SHA-256 -> base32 -> 16 chars, 22 total
with the "proxy-" prefix) satisfying GCP's RFC1035 name rules with
headroom, and idempotency-tested across 10k UIDs with zero collisions.

--providers-config YAML parsing (config.go) with fail-fast validation:
unknown type, duplicate name, missing gcp.project, mismatched
type/config-block, and strict-mode rejection of unknown keys.

internal/provider/registry/registry.go takes its type->constructor map
as a parameter rather than hardcoding it, so the package has zero import
on internal/provider/mock or internal/provider/gcp (neither exists yet —
mock is Step 3, gcp is Step 8) and compiles today. Explicit wiring moves
to the composition root in cmd/main.go (Step 10).

Deferred internal/provider/metrics.go (the WithMetrics decorator) to
Step 9, where the Prometheus vectors it needs actually get built —
nothing in this step depends on it.

internal/provider at 96.2% coverage, internal/provider/registry at 100%.
make test green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 22:44:22 +02:00

8.5 KiB

Execution log: proxy-operator

Pairs with docs/plans/2026-08-07-1747-proxy-operator.md.

Status

  • Step 0 — Branch and scaffold
  • Step 1 — API types (api/v1alpha1/proxy_types.go)
  • Step 2 — Provider contract (internal/provider/)
  • Step 3 — Mock provider (internal/provider/mock/)
  • Step 4 — Reconciler (internal/controller/)
  • Step 5 — Health engine (internal/health/)
  • Step 6 — Lease store (internal/lease/)
  • Step 7 — Discovery API (internal/discovery/)
  • Step 8 — GCP provider (internal/provider/gcp/)
  • Step 9 — Orphan GC + metrics
  • Step 10 — Wiring, config, docs
  • Step 11 — Tests
  • Verification (vet/test/kind e2e) + commit, push, open MR

Step 0 — Branch and scaffold

Branched off the unborn main:

git checkout -b feat/proxy-operator

Installed kubebuilder v4.15.0 into a scratch GOBIN rather than the default $(go env GOPATH)/bin, since the module's package layout changed and go install .../cmd/kubebuilder@v4.15.0 (the path from the plan) 404s — the binary is now the module root itself:

GOBIN=<scratch>/bin go install sigs.k8s.io/kubebuilder/v4@v4.15.0

Scaffolded in place, with kubebuilder on PATH:

kubebuilder version
# KubeBuilder: v4.15.0, Kubernetes: 1.36.0

kubebuilder init --domain example.com \
  --repo gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator --plugins go/v4
# WARN: target directory not empty (expected — CLAUDE.md, docs/, .claude/ already existed)

kubebuilder create api --group crawl --version v1alpha1 --kind Proxy \
  --resource --controller

create api auto-ran make manifests at the end, which pulled and ran controller-gen itself:

sigs.k8s.io/controller-tools/cmd/controller-gen@v0.21.0
"$(bin)/controller-gen" object:headerFile="hack/boilerplate.go.txt",year=2026 paths="./..."

Confirmed the CRD group landed correctly (no doubling — --domain example.com --group crawl was used specifically to avoid the crawl.crawl.example.com trap called out in the plan):

grep -A2 "GroupVersion =" api/v1alpha1/groupversion_info.go
# SchemeGroupVersion = schema.GroupVersion{Group: "crawl.example.com", Version: "v1alpha1"}

Dropped the scaffolded GitHub Actions workflows (remote is Gitea, not GitHub):

git rm -r --cached .github 2>/dev/null; rm -rf .github

Ran the full manifest/codegen pass once more to confirm the toolchain is reproducible end to end:

make manifests generate
# controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
# controller-gen object:headerFile="hack/boilerplate.go.txt",year=2026 paths="./..."

then go build ./... and go vet ./..., both clean with no output.

Worth noting: CONTROLLER_TOOLS_VERSION in the generated Makefile came out at v0.21.0 by default in this kubebuilder release, so the Makefile edit the plan anticipated wasn't needed. Pre-existing CLAUDE.md/CHANGELOG.md content survived untouched; kubebuilder added its own README.md, AGENTS.md, .golangci.yml, .devcontainer/, Dockerfile on top of them — those get edited or left as-is in later steps. Committed as 076bc66.

Step 1 — API types (api/v1alpha1/proxy_types.go)

Wrote the full ProxySpec/ProxyStatus/Proxy types per the plan, including the four corrections called out there (MaxLeases *int32, HealthCheck with +kubebuilder:default={}, MinLength=1 on Provider/CloudInit.Inline, Conditions with +listType=map), the 7 CEL XValidation rules (6 on ProxySpec, 1 on CloudInitSpec), and pure helpers in helpers.go (EffectivePort/EffectiveHost/HealthCheckOrDefault/MaxLeasesOrDefault) with table-driven tests in helpers_test.go.

Regenerated deepcopy and the CRD:

make manifests generate
# controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
# controller-gen object:headerFile="hack/boilerplate.go.txt",year=2026 paths="./..."

Confirmed all 7 CEL rules and the healthCheck default: {} block landed in the generated CRD as expected:

grep -B1 "rule:" config/crd/bases/crawl.example.com_proxies.yaml
# 7 matches, one per XValidation marker written

Ran the full suite, not just go build/go vet, since this was a good opportunity to confirm envtest itself works end to end for the first time:

make test
# Setting up envtest binaries for Kubernetes version 1.36...
# .../bin/k8s/1.36.2-darwin-arm64   (confirms the plan's envtest version note)

This failed on the first run — not because of anything in the new types, but because kubebuilder's scaffolded placeholder test in proxy_controller_test.go creates a bare Proxy{} with no spec.mode, which our new required/enum field correctly rejects:

Proxy.crawl.example.com "test-resource" is invalid: [spec.mode: Unsupported value: "":
supported values: "Managed", "External", ...]

That's a real envtest apiserver enforcing our schema for the first time, which is useful confirmation on its own. Patched just the resource literal in that scaffold test to a minimal valid spec (Mode: External + Endpoint.Host) rather than rewriting the file — that whole test gets replaced in Step 4 alongside the real reconciler, so a deeper fix now would be thrown away. make test then passed clean: api/v1alpha1 at 20.5% coverage (helpers only — CEL itself isn't unit-testable, it's exercised by the real apiserver as shown above), internal/controller at 66.7%.

Worth noting: the go test ./... command from the plan's own verification section does not work directly for the envtest suite — it needs KUBEBUILDER_ASSETS set, which only make test does via setup-envtest. Plain go test ./... fails the internal/controller package with a /usr/local/kubebuilder/bin/etcd: no such file error that has nothing to do with the code. Use make test, not go test ./..., whenever the controller package is in scope.

Step 2 — Provider contract (internal/provider/)

Wrote the Provider interface (Create/Get/Delete/ListByTag), Instance, Placement, CreateRequest, and the GC-contract label constants (provider.go); the error taxonomy with multi-error Unwrap() []error so errors.Is and errors.As both work off the same wrapped value (errors.go); deterministic instance naming via SHA-256 → base32 → 16 chars (name.go); and --providers-config YAML parsing with fail-fast validation (config.go).

One deliberate deviation from the plan's file layout: the plan listed internal/provider/metrics.go as part of this step, but the provider.WithMetrics decorator it describes is Step 9's concern (it needs the Prometheus vectors that don't exist until the metrics package is built) and nothing in this step depends on it existing yet. Deferred to Step 9 rather than writing a decorator with nowhere to register its metrics.

The registry package (internal/provider/registry/registry.go) came out slightly different from the plan's sketch, and better for it: instead of a package-level var builtin = map[string]Constructor{"mock": mock.New, "gcp": gcp.New} living inside the registry package, Build takes the map[string]Constructor as a parameter. This means registry has zero import on internal/provider/mock or internal/provider/gcp — neither of which exists yet at this point in the plan (mock is Step 3, gcp is Step 8) — so the package compiles today instead of only once both are done, and the explicit wiring lives at the composition root (cmd/main.go, Step 10) rather than being smeared into the registry package itself. Still fully avoids the import-cycle trap the plan called out.

Ran the full suite:

go mod tidy   # sigs.k8s.io/yaml (already an indirect dep of the k8s.io toolchain) promoted to direct
go build ./... && go vet ./...
go test -race -v ./internal/provider/...
make test

internal/provider landed at 96.2% coverage, internal/provider/registry at 100%. make test also ran go fmt ./..., which reformatted errors.go's struct-field comment alignment before its first commit — no logic change, just gofmt on a brand-new file.

Worth noting: sigs.k8s.io/yaml (not gopkg.in/yaml.v3) was picked for --providers-config parsing specifically because it has UnmarshalStrict built in (rejects unknown fields, which is what "fail fast on unknown type" in the plan actually needs) and was already pulled in transitively by the k8s.io toolchain, so no new dependency was added — go mod tidy just promoted it from indirect to direct.