241 lines
8.5 KiB
Go
241 lines
8.5 KiB
Go
// 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
|
|
}
|