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:
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