package gcp import ( "context" "errors" "strings" "testing" "time" "cloud.google.com/go/compute/apiv1/computepb" "github.com/go-logr/logr" "github.com/go-logr/logr/funcr" "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) } } // captureContext returns a ctx carrying a funcr logger that records every // emitted line, capped at the given verbosity — the test stand-in for // --zap-log-level=. func captureContext(verbosity int) (context.Context, *[]string) { lines := &[]string{} log := funcr.New(func(prefix, args string) { *lines = append(*lines, prefix+" "+args) }, funcr.Options{Verbosity: verbosity}) return logr.NewContext(context.Background(), log), lines } func runningInstance() *computepb.Instance { return &computepb.Instance{ Name: proto.String("proxy-abc123def456ghij"), Status: proto.String("RUNNING"), Zone: proto.String("https://www.googleapis.com/compute/v1/projects/my-project/zones/europe-west1-b"), CreationTimestamp: proto.String("2026-08-09T10:00:00+02:00"), Labels: map[string]string{ provider.LabelManaged: provider.LabelManagedYes, provider.LabelUID: "uid-1", }, NetworkInterfaces: []*computepb.NetworkInterface{{ AccessConfigs: []*computepb.AccessConfig{{NatIP: proto.String("34.1.2.3")}}, }}, } } func runAllOps(t *testing.T, ctx context.Context, req provider.CreateRequest) { t.Helper() inst := runningInstance() p := newTestProvider(&fakeAPI{getInst: inst, listInsts: []*computepb.Instance{inst}}) if _, err := p.Create(ctx, req); err != nil { t.Fatalf("Create: %v", err) } if _, err := p.Get(ctx, "zones/europe-west1-b/instances/proxy-abc123def456ghij"); err != nil { t.Fatalf("Get: %v", err) } if err := p.Delete(ctx, "zones/europe-west1-b/instances/proxy-abc123def456ghij"); err != nil { t.Fatalf("Delete: %v", err) } if _, err := p.ListByTag(ctx); err != nil { t.Fatalf("ListByTag: %v", err) } } func TestLogging_verbosityTiers(t *testing.T) { t.Parallel() tests := []struct { name string verbosity int wantLines []string absentLines []string }{ { name: "v0 stays silent", verbosity: 0, absentLines: []string{ "GCP instance insert submitted", "GCP instance fetched", "GCP instance delete submitted", "GCP instances listed", }, }, { name: "v1 logs one line per API call", verbosity: 1, wantLines: []string{ `"msg"="GCP instance insert submitted"`, `"opName"="op-insert"`, `"msg"="GCP instance fetched"`, `"status"="RUNNING"`, `"msg"="GCP instance delete submitted"`, `"opName"="op-delete"`, `"msg"="GCP instances listed"`, `"provider"="gcp-eu"`, }, absentLines: []string{ "GCP insert request built", "GCP listed instance", }, }, { name: "v2 adds request and per-instance detail", verbosity: 2, wantLines: []string{ `"msg"="GCP insert request built"`, `"cloudInitBytes"=`, `"msg"="GCP listed instance"`, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() ctx, lines := captureContext(tc.verbosity) req := testCreateRequest() const sentinel = "SENTINEL-cloud-init-must-never-be-logged" req.CloudInit = sentinel runAllOps(t, ctx, req) joined := strings.Join(*lines, "\n") if tc.verbosity == 0 && len(*lines) != 0 { t.Errorf("verbosity 0 logged %d lines:\n%s", len(*lines), joined) } for _, want := range tc.wantLines { if !strings.Contains(joined, want) { t.Errorf("output missing %q:\n%s", want, joined) } } for _, absent := range tc.absentLines { if strings.Contains(joined, absent) { t.Errorf("output unexpectedly contains %q:\n%s", absent, joined) } } if strings.Contains(joined, sentinel) { t.Errorf("cloud-init content leaked into logs:\n%s", joined) } }) } } func TestWireLogger_gatesAtV5(t *testing.T) { t.Parallel() tests := []struct { name string verbosity int wantDebug bool }{ {name: "v5 shows wire records", verbosity: 5, wantDebug: true}, {name: "v4 hides wire records", verbosity: 4, wantDebug: false}, {name: "v2 hides wire records", verbosity: 2, wantDebug: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() lines := &[]string{} base := funcr.New(func(prefix, args string) { *lines = append(*lines, prefix+" "+args) }, funcr.Options{Verbosity: tc.verbosity}) slogger := wireLogger(base) slogger.Debug("api request", "rpcName", "Insert") joined := strings.Join(*lines, "\n") if got := strings.Contains(joined, "api request"); got != tc.wantDebug { t.Errorf("Debug record visible = %v, want %v; output:\n%s", got, tc.wantDebug, joined) } if tc.wantDebug && !strings.Contains(joined, "rpcName") { t.Errorf("wire record lost its attrs:\n%s", joined) } }) } } func TestWireLogger_infoLandsAtV1(t *testing.T) { t.Parallel() lines := &[]string{} base := funcr.New(func(prefix, args string) { *lines = append(*lines, prefix+" "+args) }, funcr.Options{Verbosity: 1}) wireLogger(base).Info("hello") if joined := strings.Join(*lines, "\n"); !strings.Contains(joined, "hello") { t.Errorf("slog Info should land at V(1) and be visible at verbosity 1; output:\n%s", joined) } } func TestLogging_apiErrorKeepsHTTPDetail(t *testing.T) { t.Parallel() ctx, lines := captureContext(1) p := newTestProvider(&fakeAPI{insertErr: gerr(403, "quotaExceeded")}) if _, err := p.Create(ctx, testCreateRequest()); err == nil { t.Fatal("Create: want error") } joined := strings.Join(*lines, "\n") for _, want := range []string{ `"msg"="GCP API call failed"`, `"httpStatus"=403`, `"quotaExceeded"`, `"op"="create"`, } { if !strings.Contains(joined, want) { t.Errorf("output missing %q:\n%s", want, joined) } } } func TestLogging_treatedAsSuccessPathsAreExplicit(t *testing.T) { t.Parallel() ctx, lines := captureContext(1) p := newTestProvider(&fakeAPI{insertErr: gerr(409), deleteErr: gerr(404)}) if _, err := p.Create(ctx, testCreateRequest()); err != nil { t.Fatalf("Create with 409: %v", err) } if err := p.Delete(ctx, "zones/z/instances/gone"); err != nil { t.Fatalf("Delete with 404: %v", err) } joined := strings.Join(*lines, "\n") for _, want := range []string{ `"msg"="GCP instance already exists, insert treated as success"`, `"msg"="GCP instance already gone, delete treated as success"`, } { if !strings.Contains(joined, want) { t.Errorf("output missing %q:\n%s", want, joined) } } }