diff --git a/docs/architecture.md b/docs/architecture.md index 6da6eea..fb4d751 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Status:** the operator is built through Step 7 (discovery API) of +> **Status:** the operator is built through Step 8 (GCP provider) 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 and the > HTTP-driven lease/discovery path; the components table and the Decisions @@ -117,20 +117,32 @@ reconcileDelete(ctx, p) reconcileExternal(ctx, p) ──► RequeueAfter: DeletionPoll (poll until gone) ``` -### 5. What provider calls do back in the cluster (kubernetes pod provider) +### 5. What provider calls do in the outside world ```text +kubernetes pod provider (internal/provider/kubernetes/) prov.Create ──► buildPod (pure) ──► client.Create(corev1.Pod) ─┐ these cause Pod events, prov.Get ──► client.Get(Pod) → phase/IP → InstanceState │ but the operator does NOT prov.Delete ──► client.Delete(Pod, tolerate NotFound) │ watch Pods — it observes prov.ListByTag ─► client.List(Pods by labels, all namespaces) ─┘ them by polling prov.Get on each RequeueAfter tick + +gcp provider (internal/provider/gcp/) — instances.{Insert,Get,Delete,AggregatedList}, nothing else +prov.Create ──► buildInsertRequest (pure) ──► instances.Insert ─┐ fire-and-forget: + 409 alreadyExists = success (idempotent retry) │ Operation.Wait is never +prov.Get ──► instances.Get → status/NatIP → InstanceState │ called; readiness is + RUNNING without NatIP = still Provisioning │ discovered by Get polls, +prov.Delete ──► instances.Delete (404 = success) │ exactly like the pod +prov.ListByTag ─► AggregatedList(label filter, ─┘ provider + ReturnPartialSuccess: true) +providerID = zones//instances/ — zone-qualified, so Get/Delete +stay correct even mid-replacement after a zone edit ``` -The reconciler never watches provider-side resources (Pods now, GCP VMs -later). All instance-state observation is poll-based through the -`Provider` interface, so the same flow works identically for a cloud API -that has no watch mechanism at all. +The reconciler never watches provider-side resources (Pods or GCP VMs). +All instance-state observation is poll-based through the `Provider` +interface, so the same flow works identically for a cloud API that has no +watch mechanism at all. ### 6. Health engine (`internal/health/`) — probes and transitions diff --git a/docs/plans-executions/2026-08-07-1747-proxy-operator.md b/docs/plans-executions/2026-08-07-1747-proxy-operator.md index 607c5bf..042ee77 100644 --- a/docs/plans-executions/2026-08-07-1747-proxy-operator.md +++ b/docs/plans-executions/2026-08-07-1747-proxy-operator.md @@ -12,7 +12,7 @@ Pairs with [docs/plans/2026-08-07-1747-proxy-operator.md](../plans/2026-08-07-17 - [x] Step 5 — Health engine (`internal/health/`) - [x] Step 6 — Lease store (`internal/lease/`) - [x] Step 7 — Discovery API (`internal/discovery/`) -- [ ] Step 8 — GCP provider (`internal/provider/gcp/`) +- [x] Step 8 — GCP provider (`internal/provider/gcp/`) - [ ] Step 9 — Orphan GC + metrics - [ ] Step 10 — Wiring, config, docs - [ ] Step 11 — Tests @@ -811,3 +811,67 @@ indirect to direct (the server holds a `logr.Logger` field). The `--discovery-addr`, `--max-lease-ttl` flags and the `DISCOVERY_TOKEN` Secret mount arrive with `cmd/main.go` in Step 10. `docs/architecture.md` gained §7 covering the whole HTTP path and the store's sweep Runnable. + +## Step 8 — GCP provider (`internal/provider/gcp/`) + +Implemented per the plan: `gcp.go` (Provider + the flattened `instancesAPI` +test seam + providerID handling + state mapping), `insert.go` (pure +`buildInsertRequest` + config defaults), `errors.go` (HTTP-code → taxonomy +classification). Only the four calls the spec allows — instances Insert / +Get / Delete / AggregatedList — and `Operation.Wait` is never called: +Create/Delete return once the operation is submitted, `409 alreadyExists` +on Insert and `404` on Delete both count as success, which is what makes +repeat calls after a crash correct. + +Dependency added (the plan's environment check pinned it): + +```bash +go get cloud.google.com/go/compute@v1.65.0 google.golang.org/api@latest +# resolved google.golang.org/api v0.292.0; go mod tidy pulled the auth/gax chain +``` + +Key shapes, all straight from the plan: + +- **providerID `zones//instances/`** — Get/Delete parse the + zone out of the ID instead of re-reading `spec.placement.zone`, which is + wrong exactly when a zone edit is the replacement being processed. +- **The seam is not an SDK mirror** — verified the plan's premise against + the vendored source before designing around it: + `InstancesScopedListPairIterator` has an unexported `nextFunc`, so a + fake cannot construct one. The seam flattens `AggregatedList` to + `[]*computepb.Instance` and returns operations as just their name. +- `AggregatedList` sets `ReturnPartialSuccess: true` (one unreachable + zone must not fail a GC sweep) and filters by + `labels.proxy-operator-managed = true`. +- `RUNNING` without a `NatIP` maps to `Provisioning` — never publish an + empty IP. Unknown/new GCP statuses map to `Stopped`: the reconciler's + response is delete-and-recreate, always safe for cattle. +- Classification: 404→NotFound; 429 and 403-with- + `quotaExceeded`/`rateLimitExceeded`→Quota; 400/401/403-other→Permanent; + everything else (408, 5xx, network, unknown)→Transient. + +Judgment call: `placement.zone`/`machineType`/`image` are all required at +`Create` — missing values fail as `ErrPermanent` with a message naming +the empty fields, rather than inventing defaults the spec doesn't define. +A wrong guess here would silently create billable VMs of an arbitrary +shape; a Failed condition telling the user what to set is strictly better. + +Tests (75.2%, `-race -count=2` clean, green on first run): the plan's +primary field-by-field `buildInsertRequest` assertion (machine-type URL, +boot disk, the exact `{External NAT, ONE_TO_ONE_NAT}` access config, +user-data metadata, GC labels, network tag) plus config overrides and +no-metadata-without-cloud-init; the full classification table including +`errors.Is` AND `errors.As` through the multi-unwrap; and fake-seam tests +for zone-qualified IDs, 409-is-success, permanent-on-bad-placement (no +API call made), the nine-row state-mapping table, 404 paths, malformed +providerIDs, and the ListByTag filter/partial-success assertions. The +uncovered remainder is `New()` (dials real Google with ADC) and the +`realInstances` adapter — the same deliberately-untested posture as the +kubernetes provider's `New()`. + +Worth noting: gopls suggested replacing `proto.String(x)` with Go 1.26's +`new(x)` expression; left as `proto.String` — it is the universal +protobuf-construction idiom and matches every example in the SDK docs. +Registry wiring (`"gcp": gcp.New`) happens at the composition root in +Step 10, as designed in Step 2. `docs/architecture.md` §5 now shows both +providers' call mappings. diff --git a/go.mod b/go.mod index 0eb0c93..c691526 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator go 1.26.0 require ( + cloud.google.com/go/compute v1.65.0 github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 + google.golang.org/api v0.292.0 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 @@ -14,7 +17,10 @@ require ( ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect + cloud.google.com/go/auth v0.22.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -37,7 +43,10 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect + github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -56,33 +65,34 @@ require ( github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 690c70d..ed91e89 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= +cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute v1.65.0 h1:K0a3NRvazE7sZn5qswwI6BtlaZv1fgR5wFop5LZCLz8= +cloud.google.com/go/compute v1.65.0/go.mod h1:vFq+Ztj9Rzhc8zf1t6hGp/6NdrEVG1GakkyVRQPRgKc= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= @@ -68,8 +78,14 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= +github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -154,22 +170,22 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -182,36 +198,42 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU= +google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/provider/gcp/errors.go b/internal/provider/gcp/errors.go new file mode 100644 index 0000000..9fec05b --- /dev/null +++ b/internal/provider/gcp/errors.go @@ -0,0 +1,61 @@ +package gcp + +import ( + "errors" + "net/http" + "slices" + + "google.golang.org/api/googleapi" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +// classify maps a GCP API error onto the provider taxonomy: +// 404 → NotFound; 429 and quota-flavored 403s → QuotaExceeded; +// 400/401/other 403s → Permanent; 408/5xx and anything unrecognized +// (network errors, context cancellation) → Transient, because retrying is +// always safer than latching Failed on an error nobody taught this +// function to recognize. +func classify(err error) error { + var gerr *googleapi.Error + if !errors.As(err, &gerr) { + return provider.ErrTransient + } + switch { + case gerr.Code == http.StatusNotFound: + return provider.ErrNotFound + case gerr.Code == http.StatusTooManyRequests: + return provider.ErrQuotaExceeded + case gerr.Code == http.StatusForbidden && hasReason(gerr, "quotaExceeded", "rateLimitExceeded"): + return provider.ErrQuotaExceeded + case gerr.Code == http.StatusBadRequest, + gerr.Code == http.StatusUnauthorized, + gerr.Code == http.StatusForbidden: + return provider.ErrPermanent + default: + return provider.ErrTransient + } +} + +func (p *Provider) wrapErr(op, id string, err error) error { + return provider.Wrap(classify(err), op, p.name, id, err) +} + +func hasReason(gerr *googleapi.Error, reasons ...string) bool { + for _, item := range gerr.Errors { + if slices.Contains(reasons, item.Reason) { + return true + } + } + return false +} + +func isAlreadyExists(err error) bool { + var gerr *googleapi.Error + return errors.As(err, &gerr) && gerr.Code == http.StatusConflict +} + +func isNotFound(err error) bool { + var gerr *googleapi.Error + return errors.As(err, &gerr) && gerr.Code == http.StatusNotFound +} diff --git a/internal/provider/gcp/errors_test.go b/internal/provider/gcp/errors_test.go new file mode 100644 index 0000000..aa4bedb --- /dev/null +++ b/internal/provider/gcp/errors_test.go @@ -0,0 +1,71 @@ +package gcp + +import ( + "errors" + "fmt" + "testing" + + "google.golang.org/api/googleapi" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +func gerr(code int, reasons ...string) error { + e := &googleapi.Error{Code: code, Message: "boom"} + for _, r := range reasons { + e.Errors = append(e.Errors, googleapi.ErrorItem{Reason: r}) + } + return e +} + +func TestClassify(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want error + }{ + {name: "404 is NotFound", err: gerr(404), want: provider.ErrNotFound}, + {name: "429 is Quota", err: gerr(429), want: provider.ErrQuotaExceeded}, + {name: "403 quotaExceeded is Quota", err: gerr(403, "quotaExceeded"), want: provider.ErrQuotaExceeded}, + {name: "403 rateLimitExceeded is Quota", err: gerr(403, "rateLimitExceeded"), want: provider.ErrQuotaExceeded}, + {name: "403 plain is Permanent", err: gerr(403, "forbidden"), want: provider.ErrPermanent}, + {name: "400 is Permanent", err: gerr(400), want: provider.ErrPermanent}, + {name: "401 is Permanent", err: gerr(401), want: provider.ErrPermanent}, + {name: "408 is Transient", err: gerr(408), want: provider.ErrTransient}, + {name: "500 is Transient", err: gerr(500), want: provider.ErrTransient}, + {name: "503 is Transient", err: gerr(503), want: provider.ErrTransient}, + {name: "409 is Transient (alreadyExists is handled before classify)", err: gerr(409), want: provider.ErrTransient}, + {name: "plain network error is Transient", err: errors.New("connection reset"), want: provider.ErrTransient}, + {name: "wrapped googleapi error still classifies", err: fmt.Errorf("calling api: %w", gerr(404)), want: provider.ErrNotFound}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := classify(tc.err); got != tc.want { + t.Errorf("classify() = %v, want %v", got, tc.want) + } + }) + } +} + +// The wrapped error must satisfy both halves of the taxonomy contract: +// errors.Is against the sentinel AND errors.As back to the SDK error. +func TestWrapErr_isAndAsBothWork(t *testing.T) { + t.Parallel() + p := &Provider{name: "gcp-eu"} + wrapped := p.wrapErr("get", "zones/z/instances/i", gerr(404)) + + if !errors.Is(wrapped, provider.ErrNotFound) { + t.Error("errors.Is(wrapped, ErrNotFound) = false") + } + var ge *googleapi.Error + if !errors.As(wrapped, &ge) || ge.Code != 404 { + t.Error("errors.As back to *googleapi.Error failed") + } + if provider.Class(wrapped) != provider.ErrNotFound { + t.Errorf("Class() = %v, want ErrNotFound", provider.Class(wrapped)) + } +} diff --git a/internal/provider/gcp/gcp.go b/internal/provider/gcp/gcp.go new file mode 100644 index 0000000..19fc0e0 --- /dev/null +++ b/internal/provider/gcp/gcp.go @@ -0,0 +1,240 @@ +// Package gcp implements the provider contract on GCP Compute Engine via +// the modern Cloud Client Library (cloud.google.com/go/compute/apiv1), +// deliberately restricted to four calls: instances.Insert, Get, Delete, +// AggregatedList. Operations are fire-and-forget — Operation.Wait is never +// called; Create/Delete return as soon as the operation is submitted and +// the reconciler discovers progress by polling Get. +package gcp + +import ( + "context" + "fmt" + "strings" + "time" + + compute "cloud.google.com/go/compute/apiv1" + "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/api/iterator" + "google.golang.org/protobuf/proto" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +// instancesAPI is the test seam. It deliberately does not mirror the SDK: +// the SDK's InstancesScopedListPairIterator has an unexported nextFunc, so +// a fake cannot construct one — the seam flattens AggregatedList to a +// slice, and returns operations as just their name (the only thing this +// provider ever uses, since it never waits on them). +type instancesAPI interface { + Insert(ctx context.Context, req *computepb.InsertInstanceRequest) (opName string, err error) + Get(ctx context.Context, req *computepb.GetInstanceRequest) (*computepb.Instance, error) + Delete(ctx context.Context, req *computepb.DeleteInstanceRequest) (opName string, err error) + AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest) ([]*computepb.Instance, error) +} + +// realInstances adapts *compute.InstancesClient to the seam. +type realInstances struct { + client *compute.InstancesClient +} + +func (r *realInstances) Insert(ctx context.Context, req *computepb.InsertInstanceRequest) (string, error) { + op, err := r.client.Insert(ctx, req) + if err != nil { + return "", err + } + return op.Name(), nil +} + +func (r *realInstances) Get(ctx context.Context, req *computepb.GetInstanceRequest) (*computepb.Instance, error) { + return r.client.Get(ctx, req) +} + +func (r *realInstances) Delete(ctx context.Context, req *computepb.DeleteInstanceRequest) (string, error) { + op, err := r.client.Delete(ctx, req) + if err != nil { + return "", err + } + return op.Name(), nil +} + +func (r *realInstances) AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest) ([]*computepb.Instance, error) { + it := r.client.AggregatedList(ctx, req) + var out []*computepb.Instance + for { + pair, err := it.Next() + if err == iterator.Done { + return out, nil + } + if err != nil { + return nil, err + } + if pair.Value != nil { + out = append(out, pair.Value.Instances...) + } + } +} + +// Provider implements provider.Provider on GCP Compute Engine. +type Provider struct { + name string + cfg provider.GCPConfig + api instancesAPI +} + +// New builds a Provider using Application Default Credentials (workload +// identity in-cluster, gcloud ADC locally — no key-file plumbing). +// Deliberately untested: it dials real Google endpoints; everything below +// it is exercised through newWithAPI. +func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) { + client, err := compute.NewInstancesRESTClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating GCP instances client: %w", err) + } + return newWithAPI(pc, &realInstances{client: client}), nil +} + +func newWithAPI(pc provider.ProviderConfig, api instancesAPI) *Provider { + cfg := provider.GCPConfig{} + if pc.GCP != nil { + cfg = *pc.GCP + } + return &Provider{name: pc.Name, cfg: withDefaults(cfg), api: api} +} + +// Create submits the insert and returns immediately with the +// zone-qualified providerID. A 409 alreadyExists is success — the +// deterministic instance name means a repeat call after a crash found the +// VM it already created, which is exactly the idempotency the contract +// demands. +func (p *Provider) Create(ctx context.Context, req provider.CreateRequest) (string, error) { + pl := req.Placement + if pl.Zone == "" || pl.MachineType == "" || pl.Image == "" { + return "", provider.Wrap(provider.ErrPermanent, "create", p.name, "", fmt.Errorf( + "gcp requires placement.zone, placement.machineType and placement.image (got zone=%q machineType=%q image=%q)", + pl.Zone, pl.MachineType, pl.Image)) + } + id := formatProviderID(pl.Zone, req.Name) + if _, err := p.api.Insert(ctx, buildInsertRequest(p.cfg, req)); err != nil && !isAlreadyExists(err) { + return "", p.wrapErr("create", id, err) + } + return id, nil +} + +// Get returns the instance state. The providerID carries its own zone, so +// this stays correct even mid-replacement after a zone edit — re-reading +// spec.placement.zone would look up the wrong zone exactly then. +func (p *Provider) Get(ctx context.Context, providerID string) (*provider.Instance, error) { + zone, name, err := parseProviderID(providerID) + if err != nil { + return nil, provider.Wrap(provider.ErrPermanent, "get", p.name, providerID, err) + } + inst, err := p.api.Get(ctx, &computepb.GetInstanceRequest{ + Project: p.cfg.Project, + Zone: zone, + Instance: name, + }) + if err != nil { + return nil, p.wrapErr("get", providerID, err) + } + return toInstance(inst, zone), nil +} + +// Delete submits the delete and returns; deleting an instance that is +// already gone is success. +func (p *Provider) Delete(ctx context.Context, providerID string) error { + zone, name, err := parseProviderID(providerID) + if err != nil { + return provider.Wrap(provider.ErrPermanent, "delete", p.name, providerID, err) + } + if _, err := p.api.Delete(ctx, &computepb.DeleteInstanceRequest{ + Project: p.cfg.Project, + Zone: zone, + Instance: name, + }); err != nil && !isNotFound(err) { + return p.wrapErr("delete", providerID, err) + } + return nil +} + +// ListByTag sweeps every zone for instances carrying the GC labels. +// ReturnPartialSuccess matters: without it one unreachable zone fails the +// entire GC sweep. +func (p *Provider) ListByTag(ctx context.Context) ([]provider.Instance, error) { + instances, err := p.api.AggregatedList(ctx, &computepb.AggregatedListInstancesRequest{ + Project: p.cfg.Project, + Filter: proto.String(fmt.Sprintf("labels.%s = %s", provider.LabelManaged, provider.LabelManagedYes)), + ReturnPartialSuccess: proto.Bool(true), + }) + if err != nil { + return nil, p.wrapErr("list", "", err) + } + out := make([]provider.Instance, 0, len(instances)) + for _, inst := range instances { + out = append(out, *toInstance(inst, lastPathSegment(inst.GetZone()))) + } + return out, nil +} + +func toInstance(inst *computepb.Instance, zone string) *provider.Instance { + var ip string + if nics := inst.GetNetworkInterfaces(); len(nics) > 0 { + if acs := nics[0].GetAccessConfigs(); len(acs) > 0 { + ip = acs[0].GetNatIP() + } + } + // CreationTimestamp is RFC3339; a parse failure leaves the zero time, + // which orphan GC treats as "old" — safe, since a malformed timestamp + // never protects a candidate from collection forever. + created, _ := time.Parse(time.RFC3339, inst.GetCreationTimestamp()) + return &provider.Instance{ + ID: formatProviderID(zone, inst.GetName()), + IP: ip, + State: mapState(inst.GetStatus(), ip), + UID: inst.GetLabels()[provider.LabelUID], + CreatedAt: created, + } +} + +// mapState collapses GCP instance statuses onto the provider states. A +// RUNNING instance without a NatIP maps to Provisioning — an empty IP must +// never be published as Running. Anything unrecognized maps to Stopped: +// the reconciler's answer to Stopped is delete-and-recreate, which is +// always safe for cattle. +func mapState(status, ip string) provider.InstanceState { + switch status { + case "PROVISIONING", "STAGING", "REPAIRING": + return provider.StateProvisioning + case "RUNNING": + if ip == "" { + return provider.StateProvisioning + } + return provider.StateRunning + case "STOPPING", "STOPPED", "SUSPENDING", "SUSPENDED": + return provider.StateStopped + case "TERMINATED": + return provider.StateTerminated + default: + return provider.StateStopped + } +} + +func formatProviderID(zone, name string) string { + return fmt.Sprintf("zones/%s/instances/%s", zone, name) +} + +func parseProviderID(id string) (zone, name string, err error) { + parts := strings.Split(id, "/") + if len(parts) != 4 || parts[0] != "zones" || parts[2] != "instances" || parts[1] == "" || parts[3] == "" { + return "", "", fmt.Errorf("malformed gcp providerID %q, want zones//instances/", id) + } + return parts[1], parts[3], nil +} + +// lastPathSegment extracts the zone name from the URL-style +// ".../zones/europe-west1-b" the API returns on instances. +func lastPathSegment(url string) string { + if i := strings.LastIndexByte(url, '/'); i >= 0 { + return url[i+1:] + } + return url +} diff --git a/internal/provider/gcp/gcp_test.go b/internal/provider/gcp/gcp_test.go new file mode 100644 index 0000000..79f44bc --- /dev/null +++ b/internal/provider/gcp/gcp_test.go @@ -0,0 +1,271 @@ +package gcp + +import ( + "context" + "errors" + "testing" + "time" + + "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/protobuf/proto" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +// fakeAPI implements the instancesAPI seam. +type fakeAPI struct { + insertReq *computepb.InsertInstanceRequest + insertErr error + + getReq *computepb.GetInstanceRequest + getInst *computepb.Instance + getErr error + + deleteReq *computepb.DeleteInstanceRequest + deleteErr error + + listReq *computepb.AggregatedListInstancesRequest + listInsts []*computepb.Instance + listErr error +} + +func (f *fakeAPI) Insert(_ context.Context, req *computepb.InsertInstanceRequest) (string, error) { + f.insertReq = req + return "op-insert", f.insertErr +} + +func (f *fakeAPI) Get(_ context.Context, req *computepb.GetInstanceRequest) (*computepb.Instance, error) { + f.getReq = req + return f.getInst, f.getErr +} + +func (f *fakeAPI) Delete(_ context.Context, req *computepb.DeleteInstanceRequest) (string, error) { + f.deleteReq = req + return "op-delete", f.deleteErr +} + +func (f *fakeAPI) AggregatedList(_ context.Context, req *computepb.AggregatedListInstancesRequest) ([]*computepb.Instance, error) { + f.listReq = req + return f.listInsts, f.listErr +} + +func newTestProvider(api *fakeAPI) *Provider { + return newWithAPI(provider.ProviderConfig{ + Name: "gcp-eu", + Type: "gcp", + GCP: &provider.GCPConfig{Project: "my-project"}, + }, api) +} + +func TestCreate_returnsZoneQualifiedID(t *testing.T) { + t.Parallel() + api := &fakeAPI{} + p := newTestProvider(api) + + id, err := p.Create(context.Background(), testCreateRequest()) + if err != nil { + t.Fatalf("Create: %v", err) + } + if want := "zones/europe-west1-b/instances/proxy-abc123def456ghij"; id != want { + t.Errorf("providerID = %s, want %s", id, want) + } + if api.insertReq.Project != "my-project" || api.insertReq.Zone != "europe-west1-b" { + t.Errorf("insert sent to %s/%s, want my-project/europe-west1-b", api.insertReq.Project, api.insertReq.Zone) + } +} + +func TestCreate_alreadyExistsIsSuccess(t *testing.T) { + t.Parallel() + api := &fakeAPI{insertErr: gerr(409)} + p := newTestProvider(api) + + id, err := p.Create(context.Background(), testCreateRequest()) + if err != nil { + t.Fatalf("Create after crash (409): %v — alreadyExists must be success", err) + } + if want := "zones/europe-west1-b/instances/proxy-abc123def456ghij"; id != want { + t.Errorf("providerID = %s, want %s", id, want) + } +} + +func TestCreate_incompletePlacementIsPermanent(t *testing.T) { + t.Parallel() + api := &fakeAPI{} + p := newTestProvider(api) + req := testCreateRequest() + req.Placement.MachineType = "" + + _, err := p.Create(context.Background(), req) + if provider.Class(err) != provider.ErrPermanent { + t.Errorf("Class = %v, want ErrPermanent for missing placement", provider.Class(err)) + } + if api.insertReq != nil { + t.Error("Insert was called despite invalid placement") + } +} + +func TestCreate_quotaErrorClassified(t *testing.T) { + t.Parallel() + p := newTestProvider(&fakeAPI{insertErr: gerr(403, "quotaExceeded")}) + _, err := p.Create(context.Background(), testCreateRequest()) + if provider.Class(err) != provider.ErrQuotaExceeded { + t.Errorf("Class = %v, want ErrQuotaExceeded", provider.Class(err)) + } +} + +func TestGet_stateMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status string + natIP string + wantState provider.InstanceState + wantIP string + }{ + {name: "provisioning", status: "PROVISIONING", wantState: provider.StateProvisioning}, + {name: "staging", status: "STAGING", wantState: provider.StateProvisioning}, + {name: "repairing", status: "REPAIRING", wantState: provider.StateProvisioning}, + {name: "running without NatIP stays provisioning", status: "RUNNING", wantState: provider.StateProvisioning}, + {name: "running with NatIP", status: "RUNNING", natIP: "34.1.2.3", wantState: provider.StateRunning, wantIP: "34.1.2.3"}, + {name: "stopped", status: "STOPPED", wantState: provider.StateStopped}, + {name: "suspended", status: "SUSPENDED", wantState: provider.StateStopped}, + {name: "terminated", status: "TERMINATED", wantState: provider.StateTerminated}, + {name: "unknown status maps to stopped for recreation", status: "SOMETHING_NEW", wantState: provider.StateStopped}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + inst := &computepb.Instance{ + Name: proto.String("proxy-abc"), + Status: proto.String(tc.status), + CreationTimestamp: proto.String("2026-08-09T10:00:00+02:00"), + Labels: map[string]string{ + provider.LabelUID: "uid-1", + }, + } + if tc.natIP != "" { + inst.NetworkInterfaces = []*computepb.NetworkInterface{{ + AccessConfigs: []*computepb.AccessConfig{{NatIP: proto.String(tc.natIP)}}, + }} + } + p := newTestProvider(&fakeAPI{getInst: inst}) + + got, err := p.Get(context.Background(), "zones/europe-west1-b/instances/proxy-abc") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.State != tc.wantState || got.IP != tc.wantIP { + t.Errorf("state/ip = %s/%q, want %s/%q", got.State, got.IP, tc.wantState, tc.wantIP) + } + if got.UID != "uid-1" { + t.Errorf("UID = %q, want uid-1 (from the GC label)", got.UID) + } + if got.ID != "zones/europe-west1-b/instances/proxy-abc" { + t.Errorf("ID = %s, want the zone-qualified providerID", got.ID) + } + if got.CreatedAt.IsZero() { + t.Error("CreatedAt not parsed from creationTimestamp") + } + }) + } +} + +func TestGet_notFound(t *testing.T) { + t.Parallel() + p := newTestProvider(&fakeAPI{getErr: gerr(404)}) + _, err := p.Get(context.Background(), "zones/z/instances/gone") + if !errors.Is(err, provider.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestGet_malformedProviderID(t *testing.T) { + t.Parallel() + p := newTestProvider(&fakeAPI{}) + for _, id := range []string{"", "proxy-abc", "zones//instances/x", "zones/z/instances/", "z/zone/i/name"} { + if _, err := p.Get(context.Background(), id); provider.Class(err) != provider.ErrPermanent { + t.Errorf("Get(%q): Class = %v, want ErrPermanent", id, provider.Class(err)) + } + } +} + +func TestDelete_notFoundIsSuccess(t *testing.T) { + t.Parallel() + api := &fakeAPI{deleteErr: gerr(404)} + p := newTestProvider(api) + if err := p.Delete(context.Background(), "zones/z/instances/gone"); err != nil { + t.Errorf("Delete of missing instance: %v, want nil", err) + } +} + +func TestDelete_sendsParsedZoneAndName(t *testing.T) { + t.Parallel() + api := &fakeAPI{} + p := newTestProvider(api) + if err := p.Delete(context.Background(), "zones/us-east1-c/instances/proxy-xyz"); err != nil { + t.Fatalf("Delete: %v", err) + } + if api.deleteReq.Zone != "us-east1-c" || api.deleteReq.Instance != "proxy-xyz" { + t.Errorf("delete sent %s/%s, want us-east1-c/proxy-xyz", api.deleteReq.Zone, api.deleteReq.Instance) + } +} + +func TestListByTag(t *testing.T) { + t.Parallel() + api := &fakeAPI{listInsts: []*computepb.Instance{{ + Name: proto.String("proxy-old"), + Status: proto.String("RUNNING"), + Zone: proto.String("https://www.googleapis.com/compute/v1/projects/my-project/zones/europe-west1-b"), + CreationTimestamp: proto.String(time.Now().Format(time.RFC3339)), + Labels: map[string]string{ + provider.LabelManaged: provider.LabelManagedYes, + provider.LabelUID: "uid-orphan", + }, + NetworkInterfaces: []*computepb.NetworkInterface{{ + AccessConfigs: []*computepb.AccessConfig{{NatIP: proto.String("34.9.9.9")}}, + }}, + }}} + p := newTestProvider(api) + + got, err := p.ListByTag(context.Background()) + if err != nil { + t.Fatalf("ListByTag: %v", err) + } + + if want := "labels.proxy-operator-managed = true"; api.listReq.GetFilter() != want { + t.Errorf("filter = %q, want %q", api.listReq.GetFilter(), want) + } + if !api.listReq.GetReturnPartialSuccess() { + t.Error("ReturnPartialSuccess not set — one unreachable zone would fail the whole GC sweep") + } + + if len(got) != 1 { + t.Fatalf("instances = %d, want 1", len(got)) + } + if got[0].ID != "zones/europe-west1-b/instances/proxy-old" { + t.Errorf("ID = %s, want the zone parsed out of the URL-style zone field", got[0].ID) + } + if got[0].UID != "uid-orphan" || got[0].State != provider.StateRunning { + t.Errorf("instance = %+v, want uid-orphan/Running", got[0]) + } +} + +func TestListByTag_errorPropagates(t *testing.T) { + t.Parallel() + p := newTestProvider(&fakeAPI{listErr: gerr(500)}) + _, err := p.ListByTag(context.Background()) + if provider.Class(err) != provider.ErrTransient { + t.Errorf("Class = %v, want ErrTransient", provider.Class(err)) + } +} + +func TestParseProviderID_roundTrip(t *testing.T) { + t.Parallel() + id := formatProviderID("europe-west1-b", "proxy-abc") + zone, name, err := parseProviderID(id) + if err != nil || zone != "europe-west1-b" || name != "proxy-abc" { + t.Errorf("round trip = %s/%s (%v), want europe-west1-b/proxy-abc", zone, name, err) + } +} diff --git a/internal/provider/gcp/insert.go b/internal/provider/gcp/insert.go new file mode 100644 index 0000000..e60564f --- /dev/null +++ b/internal/provider/gcp/insert.go @@ -0,0 +1,74 @@ +package gcp + +import ( + "fmt" + + "cloud.google.com/go/compute/apiv1/computepb" + "google.golang.org/protobuf/proto" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +const ( + defaultNetwork = "default" + defaultNetworkTag = "proxy-operator" + defaultDiskSizeGB = 10 + userDataKey = "user-data" +) + +func withDefaults(cfg provider.GCPConfig) provider.GCPConfig { + if cfg.Network == "" { + cfg.Network = defaultNetwork + } + if cfg.NetworkTag == "" { + cfg.NetworkTag = defaultNetworkTag + } + if cfg.DiskSizeGB == 0 { + cfg.DiskSizeGB = defaultDiskSizeGB + } + return cfg +} + +// buildInsertRequest is pure so the field-by-field unit test needs no fake +// at all — the plan's primary test for this provider. +func buildInsertRequest(cfg provider.GCPConfig, req provider.CreateRequest) *computepb.InsertInstanceRequest { + inst := &computepb.Instance{ + Name: proto.String(req.Name), + MachineType: proto.String(fmt.Sprintf("zones/%s/machineTypes/%s", req.Placement.Zone, req.Placement.MachineType)), + Disks: []*computepb.AttachedDisk{{ + Boot: proto.Bool(true), + AutoDelete: proto.Bool(true), + InitializeParams: &computepb.AttachedDiskInitializeParams{ + SourceImage: proto.String(req.Placement.Image), + DiskSizeGb: proto.Int64(cfg.DiskSizeGB), + }, + }}, + NetworkInterfaces: []*computepb.NetworkInterface{{ + Network: proto.String("global/networks/" + cfg.Network), + // An ephemeral external IP: exactly this pair, per the API's + // contract for one-to-one NAT. + AccessConfigs: []*computepb.AccessConfig{{ + Name: proto.String("External NAT"), + Type: proto.String("ONE_TO_ONE_NAT"), + }}, + }}, + // The GC contract: every resource this operator creates carries + // these two labels, and orphan GC relies on both. + Labels: map[string]string{ + provider.LabelManaged: provider.LabelManagedYes, + provider.LabelUID: req.UID, + }, + Tags: &computepb.Tags{Items: []string{cfg.NetworkTag}}, + } + if req.CloudInit != "" { + inst.Metadata = &computepb.Metadata{Items: []*computepb.Items{{ + Key: proto.String(userDataKey), + Value: proto.String(req.CloudInit), + }}} + } + return &computepb.InsertInstanceRequest{ + Project: cfg.Project, + Zone: req.Placement.Zone, + InstanceResource: inst, + } +} diff --git a/internal/provider/gcp/insert_test.go b/internal/provider/gcp/insert_test.go new file mode 100644 index 0000000..a99ab21 --- /dev/null +++ b/internal/provider/gcp/insert_test.go @@ -0,0 +1,127 @@ +package gcp + +import ( + "testing" + + "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" +) + +func testCreateRequest() provider.CreateRequest { + return provider.CreateRequest{ + Name: "proxy-abc123def456ghij", + UID: "11111111-2222-3333-4444-555555555555", + Namespace: "default", + ProxyName: "eu-proxy-1", + Placement: provider.Placement{ + Zone: "europe-west1-b", + MachineType: "e2-micro", + Image: "projects/debian-cloud/global/images/family/debian-12", + }, + CloudInit: "#cloud-config\npackages: [squid]", + Port: 3128, + } +} + +func TestBuildInsertRequest_fieldByField(t *testing.T) { + t.Parallel() + cfg := withDefaults(provider.GCPConfig{Project: "my-project"}) + req := buildInsertRequest(cfg, testCreateRequest()) + + if req.Project != "my-project" || req.Zone != "europe-west1-b" { + t.Errorf("project/zone = %s/%s, want my-project/europe-west1-b", req.Project, req.Zone) + } + inst := req.InstanceResource + if inst.GetName() != "proxy-abc123def456ghij" { + t.Errorf("name = %s", inst.GetName()) + } + if got, want := inst.GetMachineType(), "zones/europe-west1-b/machineTypes/e2-micro"; got != want { + t.Errorf("machineType = %s, want %s", got, want) + } + + if len(inst.GetDisks()) != 1 { + t.Fatalf("disks = %d, want 1", len(inst.GetDisks())) + } + disk := inst.GetDisks()[0] + if !disk.GetBoot() || !disk.GetAutoDelete() { + t.Errorf("boot/autoDelete = %v/%v, want true/true", disk.GetBoot(), disk.GetAutoDelete()) + } + if got, want := disk.GetInitializeParams().GetSourceImage(), "projects/debian-cloud/global/images/family/debian-12"; got != want { + t.Errorf("sourceImage = %s, want %s", got, want) + } + if disk.GetInitializeParams().GetDiskSizeGb() != 10 { + t.Errorf("diskSizeGb = %d, want the 10 default", disk.GetInitializeParams().GetDiskSizeGb()) + } + + if len(inst.GetNetworkInterfaces()) != 1 { + t.Fatalf("networkInterfaces = %d, want 1", len(inst.GetNetworkInterfaces())) + } + nic := inst.GetNetworkInterfaces()[0] + if got, want := nic.GetNetwork(), "global/networks/default"; got != want { + t.Errorf("network = %s, want %s", got, want) + } + if len(nic.GetAccessConfigs()) != 1 { + t.Fatalf("accessConfigs = %d, want 1", len(nic.GetAccessConfigs())) + } + ac := nic.GetAccessConfigs()[0] + if ac.GetName() != "External NAT" || ac.GetType() != "ONE_TO_ONE_NAT" { + t.Errorf("accessConfig = %s/%s, want External NAT/ONE_TO_ONE_NAT", ac.GetName(), ac.GetType()) + } + + wantLabels := map[string]string{ + provider.LabelManaged: provider.LabelManagedYes, + provider.LabelUID: "11111111-2222-3333-4444-555555555555", + } + labels := inst.GetLabels() + if len(labels) != len(wantLabels) { + t.Errorf("labels = %v, want %v", labels, wantLabels) + } + for k, v := range wantLabels { + if labels[k] != v { + t.Errorf("label %s = %q, want %q", k, labels[k], v) + } + } + + if tags := inst.GetTags().GetItems(); len(tags) != 1 || tags[0] != "proxy-operator" { + t.Errorf("tags = %v, want [proxy-operator]", tags) + } + + items := inst.GetMetadata().GetItems() + if len(items) != 1 || items[0].GetKey() != "user-data" { + t.Fatalf("metadata items = %v, want one user-data entry", items) + } + if items[0].GetValue() != "#cloud-config\npackages: [squid]" { + t.Errorf("user-data = %q, want the resolved cloud-init", items[0].GetValue()) + } +} + +func TestBuildInsertRequest_configOverrides(t *testing.T) { + t.Parallel() + cfg := withDefaults(provider.GCPConfig{ + Project: "my-project", + Network: "crawl-vpc", + NetworkTag: "crawl-egress", + DiskSizeGB: 42, + }) + req := buildInsertRequest(cfg, testCreateRequest()) + inst := req.InstanceResource + + if got, want := inst.GetNetworkInterfaces()[0].GetNetwork(), "global/networks/crawl-vpc"; got != want { + t.Errorf("network = %s, want %s", got, want) + } + if tags := inst.GetTags().GetItems(); len(tags) != 1 || tags[0] != "crawl-egress" { + t.Errorf("tags = %v, want [crawl-egress]", tags) + } + if got := inst.GetDisks()[0].GetInitializeParams().GetDiskSizeGb(); got != 42 { + t.Errorf("diskSizeGb = %d, want 42", got) + } +} + +func TestBuildInsertRequest_noCloudInitMeansNoMetadata(t *testing.T) { + t.Parallel() + req := testCreateRequest() + req.CloudInit = "" + built := buildInsertRequest(withDefaults(provider.GCPConfig{Project: "p"}), req) + if built.InstanceResource.GetMetadata() != nil { + t.Errorf("metadata = %v, want none without cloud-init", built.InstanceResource.GetMetadata()) + } +}