Add the GCP provider: four-call surface, fire-and-forget ops, zone-qualified IDs
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Architecture
|
# 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).
|
> [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
|
> This document currently covers the event/reconcile flow and the
|
||||||
> HTTP-driven lease/discovery path; the components table and the Decisions
|
> 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)
|
──► 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
|
```text
|
||||||
|
kubernetes pod provider (internal/provider/kubernetes/)
|
||||||
prov.Create ──► buildPod (pure) ──► client.Create(corev1.Pod) ─┐ these cause Pod events,
|
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.Get ──► client.Get(Pod) → phase/IP → InstanceState │ but the operator does NOT
|
||||||
prov.Delete ──► client.Delete(Pod, tolerate NotFound) │ watch Pods — it observes
|
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
|
prov.ListByTag ─► client.List(Pods by labels, all namespaces) ─┘ them by polling prov.Get
|
||||||
on each RequeueAfter tick
|
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/<zone>/instances/<name> — 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
|
The reconciler never watches provider-side resources (Pods or GCP VMs).
|
||||||
later). All instance-state observation is poll-based through the
|
All instance-state observation is poll-based through the `Provider`
|
||||||
`Provider` interface, so the same flow works identically for a cloud API
|
interface, so the same flow works identically for a cloud API that has no
|
||||||
that has no watch mechanism at all.
|
watch mechanism at all.
|
||||||
|
|
||||||
### 6. Health engine (`internal/health/`) — probes and transitions
|
### 6. Health engine (`internal/health/`) — probes and transitions
|
||||||
|
|
||||||
|
|||||||
@@ -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 5 — Health engine (`internal/health/`)
|
||||||
- [x] Step 6 — Lease store (`internal/lease/`)
|
- [x] Step 6 — Lease store (`internal/lease/`)
|
||||||
- [x] Step 7 — Discovery API (`internal/discovery/`)
|
- [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 9 — Orphan GC + metrics
|
||||||
- [ ] Step 10 — Wiring, config, docs
|
- [ ] Step 10 — Wiring, config, docs
|
||||||
- [ ] Step 11 — Tests
|
- [ ] 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`
|
`--discovery-addr`, `--max-lease-ttl` flags and the `DISCOVERY_TOKEN`
|
||||||
Secret mount arrive with `cmd/main.go` in Step 10. `docs/architecture.md`
|
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.
|
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/<zone>/instances/<name>`** — 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.
|
||||||
|
|||||||
48
go.mod
48
go.mod
@@ -3,9 +3,12 @@ module gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute v1.65.0
|
||||||
github.com/go-logr/logr v1.4.3
|
github.com/go-logr/logr v1.4.3
|
||||||
github.com/onsi/ginkgo/v2 v2.27.4
|
github.com/onsi/ginkgo/v2 v2.27.4
|
||||||
github.com/onsi/gomega v1.39.0
|
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/api v0.36.0
|
||||||
k8s.io/apimachinery v0.36.0
|
k8s.io/apimachinery v0.36.0
|
||||||
k8s.io/client-go v0.36.0
|
k8s.io/client-go v0.36.0
|
||||||
@@ -14,7 +17,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // 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/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/go-cmp 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/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/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/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.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/stoewer/go-strcase v1.3.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.41.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 v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc 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/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.27.1 // indirect
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/term v0.45.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.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 v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
google.golang.org/grpc v1.83.0 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
102
go.sum
102
go.sum
@@ -1,5 +1,15 @@
|
|||||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
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 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
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=
|
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/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 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
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=
|
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=
|
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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
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.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
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 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 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 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
|
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.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
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 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
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=
|
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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
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 h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
61
internal/provider/gcp/errors.go
Normal file
61
internal/provider/gcp/errors.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
internal/provider/gcp/errors_test.go
Normal file
71
internal/provider/gcp/errors_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
240
internal/provider/gcp/gcp.go
Normal file
240
internal/provider/gcp/gcp.go
Normal file
@@ -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/<zone>/instances/<name>", 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
|
||||||
|
}
|
||||||
271
internal/provider/gcp/gcp_test.go
Normal file
271
internal/provider/gcp/gcp_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
internal/provider/gcp/insert.go
Normal file
74
internal/provider/gcp/insert.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
127
internal/provider/gcp/insert_test.go
Normal file
127
internal/provider/gcp/insert_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user