docs/gcp-in-specific-project.md: SA + firewall setup for the egress-proxy project, in-kube secret, and apply-ready ConfigMap/Deployment/Proxy manifests (Ubuntu image — debian-cloud lacks cloud-init). docs/gcp-vm-validation.md: end-to-end GCP VM validation walkthrough. docs/reviews/: proxy-operator PR review notes from 2026-08-10. .claude/: operator-reviewer agent, accumulated permission allowlist. .gitignore: never commit sa_key.json (live SA key stays untracked). Co-Authored-By: Claude <noreply@anthropic.com>
7.9 KiB
PR review findings: feat/proxy-operator
Created: 2026-08-10 11:34
Scope: origin/main...feat/proxy-operator (merge-base 076bc66, 25 commits, ~80 files)
Reviewers: go-operator-reviewer + operator-reviewer agents; findings consolidated, most severe first. Check off items as they're processed.
Both reviewers rated the core reconcile architecture sound: single status writer with one deferred patch, finalizer added before any provider call, Get-before-RemoveFinalizer on delete, CEL immutability rules correctly split to avoid the oldSelf-on-CREATE trap, leader-election gating on destructive runnables, GC tombstone rules (MinAge, UID-less instances never deleted).
Merge-blockers
-
Discovery leases proxies with an empty IP — found independently by both reviewers.
internal/discovery/handlers.go:151,internal/controller/proxy_controller.go:226During instance replacement (and the Get→NotFound recovery path) the reconciler clearsstatus.ipbut only the create branch removes theHealthycondition, and the health engine prunes state for empty-host proxies so nothing refreshes it. For the whole delete→recreate window (minutes on GCP),isHealthystill returns true andhandleAcquireLeasegrants201 Createdwith"ip": "", burning aMaxLeasesslot. Fix: addEffectiveHost() != ""toisHealthy(covers list + acquire), and remove/downgradeHealthywhereverstatus.IPis cleared. -
Orphan GC deletes other installations' fleets in a shared GCP project.
internal/provider/gcp/insert.go:57,internal/gc/gc.go:93Instances are tagged onlyproxy-operator-managed=true+ CR UID; the sweeper deletes any tagged instance whose UID isn't in its own cluster's Proxy list. Two clusters sharing a GCP project delete each other's VMs every GC interval in a permanent loop. Fix: add an installation-identity label (cluster/deployment ID) set by both providers and filtered on inListByTag. -
Permanent-error latch wedges proxies on failures that aren't spec-caused.
internal/controller/proxy_controller.go:126Latch keys onobservedGeneration == generation, but two failure inputs live outside the spec: an unconfigured provider (config fix + restart doesn't bump generation, andspec.provideris CEL-immutable → stuckFailedshort of deleting the CR) and resolved Secret content (Secret fix enqueues a reconcile that short-circuits at the latch before re-resolving cloud-init). Fix: latch should also consider current spec-hash / provider availability.
Worth fixing
-
Deletion-path failures invisible in status — flagged by both reviewers.
internal/controller/proxy_controller.go:319,:266deletionFailureswallowsErrQuotaExceeded(nil error, no status write); unconfigured provider returns a bare error forever. A Proxy wedged inDeletingshows nothing inkubectl describe. StagesetProvisioned(p, False, ReasonDeleting, ...)before returning. Also:Deleteis resubmitted on everyDeletionPollpass, churning GCP quota — a state check on theGetresult would avoid it. -
Lost providerID on
setSpecHashconflict.internal/controller/proxy_controller.go:161On Update conflict the function returns beforep.Status.ProviderID = id, so the deferred patch persists an empty providerID for a just-created instance. Self-heals via GC. Fix: setp.Status.ProviderID = idbefore returning the error (one line). -
Terminating pods still report
StateRunning.internal/provider/kubernetes/kubernetes.go:151A pod with a deletionTimestamp keepsphase=Running+PodIPwhile terminating, so drift reconcile republishesProvisioned=Trueand discovery keeps leasing a dying pod. Fix: map non-zeropod.DeletionTimestamptoStateTerminatedininstanceFromPod. -
Stale-cache spec-hash race deletes the freshly created replacement instance.
internal/controller/proxy_controller.go:176Instance name derives from CR UID, so old and new instances share a providerID. A reconcile served a cached object from before a just-completed replacement re-entersreplaceInstanceand deletes the new healthy instance. Converges, but destroys a good instance. Fix: re-read uncached before the destructive branch, or compareinst.CreatedAtagainst the annotation-update time. -
observedGenerationwritten before the generation is actually processed.internal/controller/status.go:114Set unconditionally inpatchStatusIfChanged, including on the finalizer-add pass andresolveCloudInitfailures — misleads kstatus-style tooling. Set it only once the state machine has genuinely evaluated the spec. -
No event filtering on the Proxy watch.
internal/controller/proxy_controller.go:402Every self-inflicted status patch triggers a follow-up reconcile with an extra cloudGet, roughly doubling provider read traffic. Caution: a plainGenerationChangedPredicatebreaks the finalizer flow (relies on its own Update event to re-enter) — needs a status-only/resourceVersion-only filter or an explicit requeue in the finalizer pass. -
Unlabelled cloud-init Secrets produce a misleading NotFound with endless backoff.
internal/controller/proxy_controller.go:344,cmd/main.go:210The label-restricted cache turns "exists but missingcrawl.example.com/cloud-init=true" intoCloudInitError: not found. Mention the label requirement in the condition message, or read via uncachedAPIReaderand validate the label explicitly. -
RBAC over-grant.
config/rbac/role.yaml:25create;deleteonproxiesis scaffold residue (controller never creates/deletes CRs); cluster-widepods create/deleteandsecrets get/list/watchapply even when only the GCP provider is configured — pod rules belong in an optional kustomize component.
Simplifications
-
Delete
internal/provider/registry— 14 lines of logic, one caller (cmd/main.go:148); foldBuild/Constructorinto the composition root. Also fixes the two-sources-of-truth problem:internal/provider/config.go:84hardcodes"kubernetes"/"gcp"whileregistry.Builddispatches through a caller-supplied map — validate against the constructor map instead. Net −1 package, −44 lines, −92 test lines. -
Collapse
LeaseStoreinterface to*lease.Store.internal/discovery/server.go:28Single implementation and not a test seam (tests wire the reallease.NewStore). KeepHealthSnapshotterandinstancesAPI— those are genuine seams. -
Replace metrics nil-guards with no-op defaults.
internal/health/engine.go:68,internal/discovery/server.go:38,internal/provider/metrics.go:8Keep the interfaces (legit "no prometheus in domain packages" rationale) but default the fields to a no-op impl —provider.WithMetricsalready dereferences unconditionally, so the guards are inconsistent anyway.
Nice-to-have
- Add an
OwnerReferenceto provider pods (internal/provider/kubernetes/pod.go:24) — free cascading deletion if the finalizer is ever bypassed;CreateRequestalready carries Namespace/ProxyName/UID. Close()the GCP*compute.InstancesClient(internal/provider/gcp/gcp.go:89) — harmless today, a leak the moment providers are rebuilt on config reload.- Fix
.golangciconfig: it references a missinglogcheckplugin, so the linter only runs with the project config disabled. - External-mode endpoint edits don't reset health-engine counters
(
internal/health/engine.go:206keys on name+UID): flippingendpoint.hostkeeps the old host'sHealthy=TrueforfailureThreshold × interval. Arguably a replacement, not a flap. init()funcs atapi/v1alpha1/proxy_types.go:353andcmd/main.go:67conflict with the repo's "noinit()" convention; kubebuilder-idiomatic, but scheme registration could use the scaffold'sSchemeBuilder.Registerat package var scope.