[node operator] Add GCP client

Signed-off-by: Malte Poll <mp@edgeless.systems>
This commit is contained in:
Malte Poll 2022-07-05 14:39:17 +02:00 committed by Malte Poll
parent 0618a000a7
commit 717570d00a
23 changed files with 2102 additions and 21 deletions

View file

@ -0,0 +1,53 @@
package client
import (
"context"
"github.com/googleapis/gax-go/v2"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
)
type instanceAPI interface {
Close() error
Get(ctx context.Context, req *computepb.GetInstanceRequest,
opts ...gax.CallOption) (*computepb.Instance, error)
}
type instanceTemplateAPI interface {
Close() error
Get(ctx context.Context, req *computepb.GetInstanceTemplateRequest,
opts ...gax.CallOption) (*computepb.InstanceTemplate, error)
Delete(ctx context.Context, req *computepb.DeleteInstanceTemplateRequest,
opts ...gax.CallOption) (Operation, error)
Insert(ctx context.Context, req *computepb.InsertInstanceTemplateRequest,
opts ...gax.CallOption) (Operation, error)
}
type instanceGroupManagersAPI interface {
Close() error
Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest,
opts ...gax.CallOption) (*computepb.InstanceGroupManager, error)
SetInstanceTemplate(ctx context.Context, req *computepb.SetInstanceTemplateInstanceGroupManagerRequest,
opts ...gax.CallOption) (Operation, error)
CreateInstances(ctx context.Context, req *computepb.CreateInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption) (Operation, error)
DeleteInstances(ctx context.Context, req *computepb.DeleteInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption) (Operation, error)
}
type diskAPI interface {
Close() error
Get(ctx context.Context, req *computepb.GetDiskRequest,
opts ...gax.CallOption) (*computepb.Disk, error)
}
type Operation interface {
Proto() *computepb.Operation
Done() bool
Wait(ctx context.Context, opts ...gax.CallOption) error
}
type prng interface {
// Intn returns, as an int, a non-negative pseudo-random number in the half-open interval [0,n). It panics if n <= 0.
Intn(n int) int
}

View file

@ -0,0 +1,83 @@
package client
import (
"context"
"math/rand"
"time"
compute "cloud.google.com/go/compute/apiv1"
"go.uber.org/multierr"
)
// Client is a client for the Google Compute Engine.
type Client struct {
instanceAPI
instanceTemplateAPI
instanceGroupManagersAPI
diskAPI
// prng is a pseudo-random number generator seeded with time. Not used for security.
prng
}
// New creates a new client for the Google Compute Engine.
func New(ctx context.Context) (*Client, error) {
var closers []closer
insAPI, err := compute.NewInstancesRESTClient(ctx)
if err != nil {
return nil, err
}
closers = append(closers, insAPI)
templAPI, err := compute.NewInstanceTemplatesRESTClient(ctx)
if err != nil {
_ = closeAll(closers)
return nil, err
}
closers = append(closers, templAPI)
groupAPI, err := compute.NewInstanceGroupManagersRESTClient(ctx)
if err != nil {
_ = closeAll(closers)
return nil, err
}
closers = append(closers, groupAPI)
diskAPI, err := compute.NewDisksRESTClient(ctx)
if err != nil {
_ = closeAll(closers)
return nil, err
}
return &Client{
instanceAPI: insAPI,
instanceTemplateAPI: &instanceTemplateClient{templAPI},
instanceGroupManagersAPI: &instanceGroupManagersClient{groupAPI},
diskAPI: diskAPI,
prng: rand.New(rand.NewSource(int64(time.Now().Nanosecond()))),
}, nil
}
// Close closes the client's connection.
func (c *Client) Close() error {
closers := []closer{
c.instanceAPI,
c.instanceTemplateAPI,
c.instanceGroupManagersAPI,
c.diskAPI,
}
return closeAll(closers)
}
type closer interface {
Close() error
}
// closeAll closes all closers, even if an error occurs.
//
// Errors are collected and a composed error is returned.
func closeAll(closers []closer) error {
// Since this function is intended to be deferred, it will always call all
// close operations, even if a previous operation failed.
var errs error
for _, closer := range closers {
errs = multierr.Append(errs, closer.Close())
}
return errs
}

View file

@ -0,0 +1,143 @@
package client
import (
"context"
"github.com/googleapis/gax-go/v2"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
type stubInstanceAPI struct {
instance *computepb.Instance
getErr error
}
func (a stubInstanceAPI) Close() error {
return nil
}
func (a stubInstanceAPI) Get(ctx context.Context, req *computepb.GetInstanceRequest,
opts ...gax.CallOption,
) (*computepb.Instance, error) {
return a.instance, a.getErr
}
type stubInstanceTemplateAPI struct {
template *computepb.InstanceTemplate
getErr error
deleteErr error
insertErr error
}
func (a stubInstanceTemplateAPI) Close() error {
return nil
}
func (a stubInstanceTemplateAPI) Get(ctx context.Context, req *computepb.GetInstanceTemplateRequest,
opts ...gax.CallOption,
) (*computepb.InstanceTemplate, error) {
return a.template, a.getErr
}
func (a stubInstanceTemplateAPI) Delete(ctx context.Context, req *computepb.DeleteInstanceTemplateRequest,
opts ...gax.CallOption,
) (Operation, error) {
return &stubOperation{
&computepb.Operation{
Name: proto.String("name"),
},
}, a.deleteErr
}
func (a stubInstanceTemplateAPI) Insert(ctx context.Context, req *computepb.InsertInstanceTemplateRequest,
opts ...gax.CallOption,
) (Operation, error) {
return &stubOperation{
&computepb.Operation{
Name: proto.String("name"),
},
}, a.insertErr
}
type stubInstanceGroupManagersAPI struct {
instanceGroupManager *computepb.InstanceGroupManager
getErr error
setInstanceTemplateErr error
createInstancesErr error
deleteInstancesErr error
}
func (a stubInstanceGroupManagersAPI) Close() error {
return nil
}
func (a stubInstanceGroupManagersAPI) Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (*computepb.InstanceGroupManager, error) {
return a.instanceGroupManager, a.getErr
}
func (a stubInstanceGroupManagersAPI) SetInstanceTemplate(ctx context.Context, req *computepb.SetInstanceTemplateInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
return &stubOperation{
&computepb.Operation{
Name: proto.String("name"),
},
}, a.setInstanceTemplateErr
}
func (a stubInstanceGroupManagersAPI) CreateInstances(ctx context.Context, req *computepb.CreateInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
return &stubOperation{
&computepb.Operation{
Name: proto.String("name"),
},
}, a.createInstancesErr
}
func (a stubInstanceGroupManagersAPI) DeleteInstances(ctx context.Context, req *computepb.DeleteInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
if a.deleteInstancesErr != nil {
return nil, a.deleteInstancesErr
}
return &stubOperation{
&computepb.Operation{
Name: proto.String("name"),
},
}, nil
}
type stubDiskAPI struct {
disk *computepb.Disk
getErr error
}
func (a stubDiskAPI) Close() error {
return nil
}
func (a stubDiskAPI) Get(ctx context.Context, req *computepb.GetDiskRequest,
opts ...gax.CallOption,
) (*computepb.Disk, error) {
return a.disk, a.getErr
}
type stubOperation struct {
*computepb.Operation
}
func (o *stubOperation) Proto() *computepb.Operation {
return o.Operation
}
func (o *stubOperation) Done() bool {
return true
}
func (o *stubOperation) Wait(ctx context.Context, opts ...gax.CallOption) error {
return nil
}

View file

@ -0,0 +1,36 @@
package client
import (
"fmt"
"regexp"
"google.golang.org/genproto/googleapis/cloud/compute/v1"
)
var (
diskSourceRegex = regexp.MustCompile(`^https://www.googleapis.com/compute/v1/projects/([^/]+)/zones/([^/]+)/disks/([^/]+)$`)
computeAPIBase = regexp.MustCompile(`^https://www.googleapis.com/compute/v1/(.+)$`)
)
// diskSourceToDiskReq converts a disk source URI to a disk request.
func diskSourceToDiskReq(diskSource string) (*compute.GetDiskRequest, error) {
matches := diskSourceRegex.FindStringSubmatch(diskSource)
if len(matches) != 4 {
return nil, fmt.Errorf("error splitting diskSource: %v", diskSource)
}
return &compute.GetDiskRequest{
Disk: matches[3],
Project: matches[1],
Zone: matches[2],
}, nil
}
// uriNormalize normalizes a compute API URI by removing the optional URI prefix.
// for normalization, the prefix 'https://www.googleapis.com/compute/v1/' is removed.
func uriNormalize(imageURI string) string {
matches := computeAPIBase.FindStringSubmatch(imageURI)
if len(matches) != 2 {
return imageURI
}
return matches[1]
}

View file

@ -0,0 +1,73 @@
package client
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/cloud/compute/v1"
)
func TestDiskSourceToDiskReq(t *testing.T) {
testCases := map[string]struct {
diskSource string
wantRequest *compute.GetDiskRequest
wantErr bool
}{
"valid request": {
diskSource: "https://www.googleapis.com/compute/v1/projects/project/zones/zone/disks/disk",
wantRequest: &compute.GetDiskRequest{
Disk: "disk",
Project: "project",
Zone: "zone",
},
},
"invalid host": {
diskSource: "https://hostname/compute/v1/projects/project/zones/zone/disks/disk",
wantErr: true,
},
"invalid scheme": {
diskSource: "invalid://www.googleapis.com/compute/v1/projects/project/zones/zone/disks/disk",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
request, err := diskSourceToDiskReq(tc.diskSource)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantRequest, request)
})
}
}
func TestURINormalize(t *testing.T) {
testCases := map[string]struct {
imageURI string
wantNormalized string
}{
"URI with scheme and host": {
imageURI: "https://www.googleapis.com/compute/v1/projects/project/global/images/image",
wantNormalized: "projects/project/global/images/image",
},
"normalized": {
imageURI: "projects/project/global/images/image",
wantNormalized: "projects/project/global/images/image",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
normalized := uriNormalize(tc.imageURI)
assert.Equal(tc.wantNormalized, normalized)
})
}
}

View file

@ -0,0 +1,61 @@
package client
import (
"context"
compute "cloud.google.com/go/compute/apiv1"
"github.com/googleapis/gax-go/v2"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
)
type instanceTemplateClient struct {
*compute.InstanceTemplatesClient
}
func (c *instanceTemplateClient) Close() error {
return c.InstanceTemplatesClient.Close()
}
func (c *instanceTemplateClient) Delete(ctx context.Context, req *computepb.DeleteInstanceTemplateRequest,
opts ...gax.CallOption,
) (Operation, error) {
return c.InstanceTemplatesClient.Delete(ctx, req, opts...)
}
func (c *instanceTemplateClient) Insert(ctx context.Context, req *computepb.InsertInstanceTemplateRequest,
opts ...gax.CallOption,
) (Operation, error) {
return c.InstanceTemplatesClient.Insert(ctx, req, opts...)
}
type instanceGroupManagersClient struct {
*compute.InstanceGroupManagersClient
}
func (c *instanceGroupManagersClient) Close() error {
return c.InstanceGroupManagersClient.Close()
}
func (c *instanceGroupManagersClient) Get(ctx context.Context, req *computepb.GetInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (*computepb.InstanceGroupManager, error) {
return c.InstanceGroupManagersClient.Get(ctx, req, opts...)
}
func (c *instanceGroupManagersClient) SetInstanceTemplate(ctx context.Context, req *computepb.SetInstanceTemplateInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
return c.InstanceGroupManagersClient.SetInstanceTemplate(ctx, req, opts...)
}
func (c *instanceGroupManagersClient) CreateInstances(ctx context.Context, req *computepb.CreateInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
return c.InstanceGroupManagersClient.CreateInstances(ctx, req, opts...)
}
func (c *instanceGroupManagersClient) DeleteInstances(ctx context.Context, req *computepb.DeleteInstancesInstanceGroupManagerRequest,
opts ...gax.CallOption,
) (Operation, error) {
return c.InstanceGroupManagersClient.DeleteInstances(ctx, req, opts...)
}

View file

@ -0,0 +1,29 @@
package client
import (
"fmt"
"regexp"
)
var instanceGroupIDRegex = regexp.MustCompile(`^projects/([^/]+)/zones/([^/]+)/instanceGroupManagers/([^/]+)$`)
// splitInstanceGroupID splits an instance group ID into core components.
func splitInstanceGroupID(instanceGroupID string) (project, zone, instanceGroup string, err error) {
matches := instanceGroupIDRegex.FindStringSubmatch(instanceGroupID)
if len(matches) != 4 {
return "", "", "", fmt.Errorf("error splitting instanceGroupID: %v", instanceGroupID)
}
return matches[1], matches[2], matches[3], nil
}
// generateInstanceName generates a random instance name.
func generateInstanceName(baseInstanceName string, random prng) (string, error) {
letters := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
const uidLen = 4
uid := make([]byte, 0, uidLen)
for i := 0; i < uidLen; i++ {
n := random.Intn(len(letters))
uid = append(uid, letters[n])
}
return baseInstanceName + "-" + string(uid), nil
}

View file

@ -0,0 +1,76 @@
package client
import (
"math/rand"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplitInstanceGroupID(t *testing.T) {
testCases := map[string]struct {
instanceGroupID string
wantProject string
wantZone string
wantInstanceGroup string
wantErr bool
}{
"valid request": {
instanceGroupID: "projects/project/zones/zone/instanceGroupManagers/instanceGroup",
wantProject: "project",
wantZone: "zone",
wantInstanceGroup: "instanceGroup",
},
"wrong format": {
instanceGroupID: "wrong-format",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
gotProject, gotZone, gotInstanceGroup, err := splitInstanceGroupID(tc.instanceGroupID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantProject, gotProject)
assert.Equal(tc.wantZone, gotZone)
assert.Equal(tc.wantInstanceGroup, gotInstanceGroup)
})
}
}
func TestGenerateInstanceName(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
baseInstanceName := "base"
gotInstanceName, err := generateInstanceName(baseInstanceName, &stubRng{result: 0})
require.NoError(err)
assert.Equal("base-aaaa", gotInstanceName)
}
func TestGenerateInstanceNameRandomTest(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
instanceNameRegexp := regexp.MustCompile(`^base-[0-9a-z]{4}$`)
baseInstanceName := "base"
random := rand.New(rand.NewSource(int64(time.Now().Nanosecond())))
gotInstanceName, err := generateInstanceName(baseInstanceName, random)
require.NoError(err)
assert.Regexp(instanceNameRegexp, gotInstanceName)
}
type stubRng struct {
result int
}
func (r *stubRng) Intn(n int) int {
return r.result
}

View file

@ -0,0 +1,47 @@
package client
import (
"fmt"
"math"
"regexp"
"strconv"
)
var (
numberedNameRegex = regexp.MustCompile(`^(.+)-(\d+)$`)
instanceTemplateIDRegex = regexp.MustCompile(`projects/([^/]+)/global/instanceTemplates/([^/]+)`)
)
// generateInstanceTemplateName generates a unique name for an instance template by incrementing a counter.
// The name is in the format <prefix>-<counter>.
func generateInstanceTemplateName(last string) (string, error) {
if len(last) > 0 && last[len(last)-1] == '-' {
return last + "1", nil
}
matches := numberedNameRegex.FindStringSubmatch(last)
if len(matches) != 3 {
return last + "-1", nil
}
n, err := strconv.Atoi(matches[2])
if err != nil {
return "", err
}
if n < 1 || n == math.MaxInt {
return "", fmt.Errorf("invalid counter: %v", n)
}
return matches[1] + "-" + strconv.Itoa(n+1), nil
}
// splitInstanceTemplateID splits an instance template ID into its project and name components.
func splitInstanceTemplateID(instanceTemplateID string) (project, templateName string, err error) {
matches := instanceTemplateIDRegex.FindStringSubmatch(instanceTemplateID)
if len(matches) != 3 {
return "", "", fmt.Errorf("error splitting instanceTemplateID: %v", instanceTemplateID)
}
return matches[1], matches[2], nil
}
// joinInstanceTemplateURI joins a project and template name into an instance template URI.
func joinInstanceTemplateURI(project, templateName string) string {
return fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%v/global/instanceTemplates/%v", project, templateName)
}

View file

@ -0,0 +1,99 @@
package client
import (
"fmt"
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateInstanceTemplateName(t *testing.T) {
testCases := map[string]struct {
last string
wantNext string
wantErr bool
}{
"no numbering yet": {
last: "prefix",
wantNext: "prefix-1",
},
"ends in -": {
last: "prefix-",
wantNext: "prefix-1",
},
"has number": {
last: "prefix-1",
wantNext: "prefix-2",
},
"last number too small": {
last: "prefix-0",
wantErr: true,
},
"last number would overflow": {
last: fmt.Sprintf("prefix-%d", math.MaxInt),
wantErr: true,
},
"integer out of range": {
last: "prefix-999999999999999999999999999999999999999999",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
gotNext, err := generateInstanceTemplateName(tc.last)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantNext, gotNext)
})
}
}
func TestSplitInstanceTemplateID(t *testing.T) {
testCases := map[string]struct {
instanceTemplateID string
wantProject string
wantTemplateName string
wantErr bool
}{
"valid request": {
instanceTemplateID: "projects/project/global/instanceTemplates/template",
wantProject: "project",
wantTemplateName: "template",
},
"wrong format": {
instanceTemplateID: "wrong-format",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
gotProject, gotTemplateName, err := splitInstanceTemplateID(tc.instanceTemplateID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantProject, gotProject)
assert.Equal(tc.wantTemplateName, gotTemplateName)
})
}
}
func TestJoinInstanceTemplateID(t *testing.T) {
assert := assert.New(t)
project := "project"
templateName := "template"
wantInstanceTemplateURI := "https://www.googleapis.com/compute/v1/projects/project/global/instanceTemplates/template"
gotInstancetemplateURI := joinInstanceTemplateURI(project, templateName)
assert.Equal(wantInstanceTemplateURI, gotInstancetemplateURI)
}

View file

@ -0,0 +1,21 @@
package client
import (
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
)
// getMetadataByKey returns the value of the metadata key in the given metadata.
func getMetadataByKey(metadata *computepb.Metadata, key string) string {
if metadata == nil {
return ""
}
for _, item := range metadata.Items {
if item.Key == nil || item.Value == nil {
continue
}
if *item.Key == key {
return *item.Value
}
}
return ""
}

View file

@ -0,0 +1,57 @@
package client
import (
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
func TestGetMetadataByKey(t *testing.T) {
testCases := map[string]struct {
metadata *compute.Metadata
key string
wantValue string
}{
"metadata has key": {
metadata: &compute.Metadata{
Items: []*compute.Items{
{Key: proto.String("key"), Value: proto.String("value")},
},
},
key: "key",
wantValue: "value",
},
"metadata does not have key": {
metadata: &compute.Metadata{
Items: []*compute.Items{
{Key: proto.String("otherkey"), Value: proto.String("value")},
},
},
key: "key",
wantValue: "",
},
"metadata contains invalid item": {
metadata: &compute.Metadata{
Items: []*compute.Items{
{},
{Key: proto.String("key"), Value: proto.String("value")},
},
},
key: "key",
wantValue: "value",
},
"metadata is nil": {
key: "key",
wantValue: "",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
assert.Equal(tc.wantValue, getMetadataByKey(tc.metadata, tc.key))
})
}
}

View file

@ -0,0 +1,134 @@
package client
import (
"context"
"fmt"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
// GetNodeImage returns the image name of the node.
func (c *Client) GetNodeImage(ctx context.Context, providerID string) (string, error) {
project, zone, instanceName, err := splitProviderID(providerID)
if err != nil {
return "", err
}
instance, err := c.instanceAPI.Get(ctx, &computepb.GetInstanceRequest{
Instance: instanceName,
Project: project,
Zone: zone,
})
if err != nil {
return "", err
}
// first disk is always the boot disk
if len(instance.Disks) < 1 {
return "", fmt.Errorf("instance %v has no disks", instanceName)
}
if instance.Disks[0] == nil || instance.Disks[0].Source == nil {
return "", fmt.Errorf("instance %q has invalid disk", instanceName)
}
diskReq, err := diskSourceToDiskReq(*instance.Disks[0].Source)
if err != nil {
return "", err
}
disk, err := c.diskAPI.Get(ctx, diskReq)
if err != nil {
return "", err
}
if disk.SourceImage == nil {
return "", fmt.Errorf("disk %q has no source image", diskReq.Disk)
}
return uriNormalize(*disk.SourceImage), nil
}
// GetScalingGroupID returns the scaling group ID of the node.
func (c *Client) GetScalingGroupID(ctx context.Context, providerID string) (string, error) {
project, zone, instanceName, err := splitProviderID(providerID)
if err != nil {
return "", err
}
instance, err := c.instanceAPI.Get(ctx, &computepb.GetInstanceRequest{
Instance: instanceName,
Project: project,
Zone: zone,
})
if err != nil {
return "", fmt.Errorf("getting instance %q: %w", instanceName, err)
}
scalingGroupID := getMetadataByKey(instance.Metadata, "created-by")
if scalingGroupID == "" {
return "", fmt.Errorf("instance %q has no created-by metadata", instanceName)
}
return scalingGroupID, nil
}
// CreateNode creates a node in the specified scaling group.
func (c *Client) CreateNode(ctx context.Context, scalingGroupID string) (nodeName, providerID string, err error) {
project, zone, instanceGroupName, err := splitInstanceGroupID(scalingGroupID)
if err != nil {
return "", "", err
}
instanceGroupManager, err := c.instanceGroupManagersAPI.Get(ctx, &computepb.GetInstanceGroupManagerRequest{
InstanceGroupManager: instanceGroupName,
Project: project,
Zone: zone,
})
if err != nil {
return "", "", err
}
if instanceGroupManager.BaseInstanceName == nil {
return "", "", fmt.Errorf("instance group manager %q has no base instance name", instanceGroupName)
}
instanceName, err := generateInstanceName(*instanceGroupManager.BaseInstanceName, c.prng)
if err != nil {
return "", "", err
}
op, err := c.instanceGroupManagersAPI.CreateInstances(ctx, &computepb.CreateInstancesInstanceGroupManagerRequest{
InstanceGroupManager: instanceGroupName,
Project: project,
Zone: zone,
InstanceGroupManagersCreateInstancesRequestResource: &computepb.InstanceGroupManagersCreateInstancesRequest{
Instances: []*computepb.PerInstanceConfig{
{Name: proto.String(instanceName)},
},
},
})
if err != nil {
return "", "", err
}
if err := op.Wait(ctx); err != nil {
return "", "", err
}
return instanceName, joinProviderID(project, zone, instanceName), nil
}
// DeleteNode deletes a node specified by its provider ID.
func (c *Client) DeleteNode(ctx context.Context, providerID string) error {
_, zone, instanceName, err := splitProviderID(providerID)
if err != nil {
return err
}
scalingGroupID, err := c.GetScalingGroupID(ctx, providerID)
if err != nil {
return err
}
instanceGroupProject, instanceGroupZone, instanceGroupName, err := splitInstanceGroupID(scalingGroupID)
if err != nil {
return err
}
instanceID := joinInstanceID(zone, instanceName)
op, err := c.instanceGroupManagersAPI.DeleteInstances(ctx, &computepb.DeleteInstancesInstanceGroupManagerRequest{
InstanceGroupManager: instanceGroupName,
Project: instanceGroupProject,
Zone: instanceGroupZone,
InstanceGroupManagersDeleteInstancesRequestResource: &computepb.InstanceGroupManagersDeleteInstancesRequest{
Instances: []string{instanceID},
},
})
if err != nil {
return fmt.Errorf("deleting instance %q from instance group manager %q: %w", instanceID, scalingGroupID, err)
}
return op.Wait(ctx)
}

View file

@ -0,0 +1,292 @@
package client
import (
"context"
"errors"
"math/rand"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
func TestGetNodeImage(t *testing.T) {
testCases := map[string]struct {
providerID string
attachedDisks []*computepb.AttachedDisk
disk *computepb.Disk
getInstanceErr error
getDiskErr error
wantImage string
wantErr bool
}{
"boot disk is found": {
providerID: "gce://project/zone/instance-name",
attachedDisks: []*computepb.AttachedDisk{
{
Source: proto.String("https://www.googleapis.com/compute/v1/projects/project/zones/zone/disks/disk"),
},
},
disk: &computepb.Disk{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image"),
},
wantImage: "projects/project/global/images/image",
},
"splitting providerID fails": {
providerID: "invalid",
wantErr: true,
},
"get instance fails": {
providerID: "gce://project/zone/instance-name",
getInstanceErr: errors.New("get instance error"),
wantErr: true,
},
"instance has no disks": {
providerID: "gce://project/zone/instance-name",
wantErr: true,
},
"attached disk is invalid": {
providerID: "gce://project/zone/instance-name",
attachedDisks: []*computepb.AttachedDisk{{}},
wantErr: true,
},
"boot disk reference is invalid": {
providerID: "gce://project/zone/instance-name",
attachedDisks: []*computepb.AttachedDisk{{
Source: proto.String("invalid"),
}},
wantErr: true,
},
"get disk fails": {
providerID: "gce://project/zone/instance-name",
attachedDisks: []*computepb.AttachedDisk{{
Source: proto.String("https://www.googleapis.com/compute/v1/projects/project/zones/zone/disks/disk"),
}},
getDiskErr: errors.New("get disk error"),
wantErr: true,
},
"disk has no source image": {
providerID: "gce://project/zone/instance-name",
attachedDisks: []*computepb.AttachedDisk{{
Source: proto.String("https://www.googleapis.com/compute/v1/projects/project/zones/zone/disks/disk"),
}},
disk: &computepb.Disk{},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceAPI: &stubInstanceAPI{
getErr: tc.getInstanceErr,
instance: &computepb.Instance{
Disks: tc.attachedDisks,
},
},
diskAPI: &stubDiskAPI{
getErr: tc.getDiskErr,
disk: tc.disk,
},
}
gotImage, err := client.GetNodeImage(context.Background(), tc.providerID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantImage, gotImage)
})
}
}
func TestGetScalingGroupID(t *testing.T) {
testCases := map[string]struct {
providerID string
createdBy string
getInstanceErr error
wantScalingGroupID string
wantErr bool
}{
"scaling group is found": {
providerID: "gce://project/zone/instance-name",
createdBy: "projects/project/zones/zone/instanceGroups/instance-group",
wantScalingGroupID: "projects/project/zones/zone/instanceGroups/instance-group",
},
"splitting providerID fails": {
providerID: "invalid",
wantErr: true,
},
"get instance fails": {
providerID: "gce://project/zone/instance-name",
getInstanceErr: errors.New("get instance error"),
wantErr: true,
},
"instance has no created-by": {
providerID: "gce://project/zone/instance-name",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
instance := computepb.Instance{}
if tc.createdBy != "" {
instance.Metadata = &computepb.Metadata{
Items: []*computepb.Items{
{
Key: proto.String("created-by"),
Value: proto.String(tc.createdBy),
},
},
}
}
client := Client{
instanceAPI: &stubInstanceAPI{
getErr: tc.getInstanceErr,
instance: &instance,
},
}
gotScalingGroupID, err := client.GetScalingGroupID(context.Background(), tc.providerID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantScalingGroupID, gotScalingGroupID)
})
}
}
func TestCreateNode(t *testing.T) {
testCases := map[string]struct {
scalingGroupID string
baseInstanceName *string
getInstanceGroupManagerErr error
createInstanceErr error
wantErr bool
}{
"scaling group is found": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
baseInstanceName: proto.String("base-name"),
},
"splitting scalingGroupID fails": {
scalingGroupID: "invalid",
wantErr: true,
},
"get instance group manager fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
getInstanceGroupManagerErr: errors.New("get instance group manager error"),
wantErr: true,
},
"instance group manager has no base instance name": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
wantErr: true,
},
"create instance fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
baseInstanceName: proto.String("base-name"),
createInstanceErr: errors.New("create instance error"),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceGroupManagersAPI: &stubInstanceGroupManagersAPI{
getErr: tc.getInstanceGroupManagerErr,
createInstancesErr: tc.createInstanceErr,
instanceGroupManager: &computepb.InstanceGroupManager{
BaseInstanceName: tc.baseInstanceName,
},
},
prng: rand.New(rand.NewSource(int64(time.Now().Nanosecond()))),
}
instanceName, providerID, err := client.CreateNode(context.Background(), tc.scalingGroupID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Contains(instanceName, "base-name")
assert.Contains(providerID, "base-name")
})
}
}
func TestDeleteNode(t *testing.T) {
testCases := map[string]struct {
providerID string
scalingGroupID string
getInstanceErr error
deleteInstanceErr error
wantErr bool
}{
"node is deleted": {
providerID: "gce://project/zone/instance-name",
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
},
"splitting providerID fails": {
providerID: "invalid",
wantErr: true,
},
"get instance fails": {
providerID: "gce://project/zone/instance-name",
getInstanceErr: errors.New("get instance error"),
wantErr: true,
},
"splitting scalingGroupID fails": {
providerID: "gce://project/zone/instance-name",
scalingGroupID: "invalid",
wantErr: true,
},
"delete instance fails": {
providerID: "gce://project/zone/instance-name",
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
deleteInstanceErr: errors.New("delete instance error"),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceGroupManagersAPI: &stubInstanceGroupManagersAPI{
deleteInstancesErr: tc.deleteInstanceErr,
},
instanceAPI: &stubInstanceAPI{
getErr: tc.getInstanceErr,
instance: &computepb.Instance{
Metadata: &computepb.Metadata{
Items: []*computepb.Items{
{Key: proto.String("created-by"), Value: &tc.scalingGroupID},
},
},
},
},
}
err := client.DeleteNode(context.Background(), tc.providerID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
})
}
}

View file

@ -0,0 +1,59 @@
package client
import (
"context"
"errors"
"net/http"
"github.com/edgelesssys/constellation/operators/constellation-node-operator/api/v1alpha1"
updatev1alpha1 "github.com/edgelesssys/constellation/operators/constellation-node-operator/api/v1alpha1"
"google.golang.org/api/googleapi"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
)
// GetNodeState returns the state of the node.
func (c *Client) GetNodeState(ctx context.Context, providerID string) (updatev1alpha1.CSPNodeState, error) {
project, zone, instanceName, err := splitProviderID(providerID)
if err != nil {
return "", err
}
instance, err := c.instanceAPI.Get(ctx, &computepb.GetInstanceRequest{
Instance: instanceName,
Project: project,
Zone: zone,
})
if err != nil {
var apiErr *googleapi.Error
if errors.As(err, &apiErr) {
if apiErr.Code == http.StatusNotFound {
return v1alpha1.NodeStateTerminated, nil
}
}
return "", err
}
if instance.Status == nil {
return v1alpha1.NodeStateUnknown, nil
}
// reference: https://cloud.google.com/compute/docs/instances/instance-life-cycle
switch *instance.Status {
case computepb.Instance_PROVISIONING.String():
fallthrough
case computepb.Instance_STAGING.String():
return v1alpha1.NodeStateCreating, nil
case computepb.Instance_RUNNING.String():
return v1alpha1.NodeStateReady, nil
case computepb.Instance_STOPPING.String():
fallthrough
case computepb.Instance_SUSPENDING.String():
fallthrough
case computepb.Instance_SUSPENDED.String():
fallthrough
case computepb.Instance_REPAIRING.String():
fallthrough
case computepb.Instance_TERMINATED.String(): // this is stopped in GCP terms
return v1alpha1.NodeStateStopped, nil
}
return v1alpha1.NodeStateUnknown, nil
}

View file

@ -0,0 +1,114 @@
package client
import (
"context"
"errors"
"net/http"
"testing"
updatev1alpha1 "github.com/edgelesssys/constellation/operators/constellation-node-operator/api/v1alpha1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/googleapi"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
func TestGetNodeState(t *testing.T) {
testCases := map[string]struct {
providerID string
getInstanceErr error
instanceStatus *string
wantNodeState updatev1alpha1.CSPNodeState
wantErr bool
}{
"node is deleted and API returns 404": {
providerID: "gce://project/zone/instance-name",
getInstanceErr: &googleapi.Error{
Code: http.StatusNotFound,
},
wantNodeState: updatev1alpha1.NodeStateTerminated,
},
"splitting providerID fails": {
providerID: "invalid",
wantErr: true,
},
"node is deleted and API returns other error": {
providerID: "gce://project/zone/instance-name",
getInstanceErr: errors.New("get instance error"),
wantErr: true,
},
"instance has no status": {
providerID: "gce://project/zone/instance-name",
wantNodeState: updatev1alpha1.NodeStateUnknown,
},
"instance is provisioning": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("PROVISIONING"),
wantNodeState: updatev1alpha1.NodeStateCreating,
},
"instance is staging": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("STAGING"),
wantNodeState: updatev1alpha1.NodeStateCreating,
},
"instance is running": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("RUNNING"),
wantNodeState: updatev1alpha1.NodeStateReady,
},
"instance is stopping": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("STOPPING"),
wantNodeState: updatev1alpha1.NodeStateStopped,
},
"instance is suspending": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("SUSPENDING"),
wantNodeState: updatev1alpha1.NodeStateStopped,
},
"instance is suspended": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("SUSPENDED"),
wantNodeState: updatev1alpha1.NodeStateStopped,
},
"instance is repairing": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("REPAIRING"),
wantNodeState: updatev1alpha1.NodeStateStopped,
},
"instance terminated": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("TERMINATED"),
wantNodeState: updatev1alpha1.NodeStateStopped,
},
"instance state unknown": {
providerID: "gce://project/zone/instance-name",
instanceStatus: proto.String("unknown"),
wantNodeState: updatev1alpha1.NodeStateUnknown,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceAPI: &stubInstanceAPI{
getErr: tc.getInstanceErr,
instance: &computepb.Instance{
Status: tc.instanceStatus,
},
},
}
nodeState, err := client.GetNodeState(context.Background(), tc.providerID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantNodeState, nodeState)
})
}
}

View file

@ -0,0 +1,30 @@
package client
import (
"fmt"
"regexp"
)
var providerIDRegex = regexp.MustCompile(`^gce://([^/]+)/([^/]+)/([^/]+)$`)
// splitProviderID splits a provider's id into core components.
// A providerID is build after the schema 'gce://<project-id>/<zone>/<instance-name>'
func splitProviderID(providerID string) (project, zone, instance string, err error) {
matches := providerIDRegex.FindStringSubmatch(providerID)
if len(matches) != 4 {
return "", "", "", fmt.Errorf("splitting providerID: %q. matches: %v", providerID, matches)
}
return matches[1], matches[2], matches[3], nil
}
// joinProviderID builds a k8s provider ID for GCP instances.
// A providerID is build after the schema 'gce://<project-id>/<zone>/<instance-name>'
func joinProviderID(project, zone, instanceName string) string {
return fmt.Sprintf("gce://%v/%v/%v", project, zone, instanceName)
}
// joinInstanceID builds a gcp instance ID from the zone and instance name.
func joinInstanceID(zone, instanceName string) string {
return fmt.Sprintf("zones/%v/instances/%v", zone, instanceName)
}

View file

@ -0,0 +1,99 @@
package client
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSplitProviderID(t *testing.T) {
testCases := map[string]struct {
providerID string
wantProjectID string
wantZone string
wantInstance string
wantErr bool
}{
"simple id": {
providerID: "gce://someProject/someZone/someInstance",
wantProjectID: "someProject",
wantZone: "someZone",
wantInstance: "someInstance",
},
"incomplete id": {
providerID: "gce://someProject/someZone",
wantErr: true,
},
"wrong provider": {
providerID: "azure://someProject/someZone/someInstance",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
projectID, zone, instance, err := splitProviderID(tc.providerID)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantProjectID, projectID)
assert.Equal(tc.wantZone, zone)
assert.Equal(tc.wantInstance, instance)
})
}
}
func TestJoinProviderID(t *testing.T) {
testCases := map[string]struct {
projectID string
zone string
instance string
wantProviderID string
}{
"simple id": {
projectID: "someProject",
zone: "someZone",
instance: "someInstance",
wantProviderID: "gce://someProject/someZone/someInstance",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
providerID := joinProviderID(tc.projectID, tc.zone, tc.instance)
assert.Equal(tc.wantProviderID, providerID)
})
}
}
func TestJoinnstanceID(t *testing.T) {
testCases := map[string]struct {
zone string
instanceName string
wantInstanceID string
}{
"simple id": {
zone: "someZone",
instanceName: "someInstance",
wantInstanceID: "zones/someZone/instances/someInstance",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
providerID := joinInstanceID(tc.zone, tc.instanceName)
assert.Equal(tc.wantInstanceID, providerID)
})
}
}

View file

@ -0,0 +1,118 @@
package client
import (
"context"
"errors"
"fmt"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
)
// GetScalingGroupImage returns the image URI of the scaling group.
func (c *Client) GetScalingGroupImage(ctx context.Context, scalingGroupID string) (string, error) {
instanceTemplate, err := c.getScalingGroupTemplate(ctx, scalingGroupID)
if err != nil {
return "", err
}
return instanceTemplateSourceImage(instanceTemplate)
}
// SetScalingGroupImage sets the image URI of the scaling group.
func (c *Client) SetScalingGroupImage(ctx context.Context, scalingGroupID, imageURI string) error {
project, zone, instanceGroupName, err := splitInstanceGroupID(scalingGroupID)
if err != nil {
return err
}
// get current template
instanceTemplate, err := c.getScalingGroupTemplate(ctx, scalingGroupID)
if err != nil {
return err
}
// check if template already uses the same image
oldImageURI, err := instanceTemplateSourceImage(instanceTemplate)
if err != nil {
return err
}
if oldImageURI == imageURI {
return nil
}
// clone template with desired image
if instanceTemplate.Name == nil {
return fmt.Errorf("instance template of scaling group %q has no name", scalingGroupID)
}
instanceTemplate.Properties.Disks[0].InitializeParams.SourceImage = &imageURI
newTemplateName, err := generateInstanceTemplateName(*instanceTemplate.Name)
if err != nil {
return err
}
instanceTemplate.Name = &newTemplateName
op, err := c.instanceTemplateAPI.Insert(ctx, &computepb.InsertInstanceTemplateRequest{
Project: project,
InstanceTemplateResource: instanceTemplate,
})
if err != nil {
return fmt.Errorf("cloning instance template: %w", err)
}
if err := op.Wait(ctx); err != nil {
return fmt.Errorf("waiting for cloned instance template: %w", err)
}
newTemplateURI := joinInstanceTemplateURI(project, newTemplateName)
// update instance group manager to use new template
op, err = c.instanceGroupManagersAPI.SetInstanceTemplate(ctx, &computepb.SetInstanceTemplateInstanceGroupManagerRequest{
InstanceGroupManager: instanceGroupName,
Project: project,
Zone: zone,
InstanceGroupManagersSetInstanceTemplateRequestResource: &computepb.InstanceGroupManagersSetInstanceTemplateRequest{
InstanceTemplate: &newTemplateURI,
},
})
if err != nil {
return fmt.Errorf("setting instance template: %w", err)
}
if err := op.Wait(ctx); err != nil {
return fmt.Errorf("waiting for setting instance template: %w", err)
}
return nil
}
func (c *Client) getScalingGroupTemplate(ctx context.Context, scalingGroupID string) (*computepb.InstanceTemplate, error) {
project, zone, instanceGroupName, err := splitInstanceGroupID(scalingGroupID)
if err != nil {
return nil, err
}
instanceGroupManager, err := c.instanceGroupManagersAPI.Get(ctx, &computepb.GetInstanceGroupManagerRequest{
InstanceGroupManager: instanceGroupName,
Project: project,
Zone: zone,
})
if err != nil {
return nil, fmt.Errorf("getting instance group manager %q: %w", instanceGroupName, err)
}
if instanceGroupManager.InstanceTemplate == nil {
return nil, fmt.Errorf("instance group manager %q has no instance template", instanceGroupName)
}
instanceTemplateProject, instanceTemplateName, err := splitInstanceTemplateID(uriNormalize(*instanceGroupManager.InstanceTemplate))
if err != nil {
return nil, fmt.Errorf("splitting instance template name: %w", err)
}
instanceTemplate, err := c.instanceTemplateAPI.Get(ctx, &computepb.GetInstanceTemplateRequest{
InstanceTemplate: instanceTemplateName,
Project: instanceTemplateProject,
})
if err != nil {
return nil, fmt.Errorf("getting instance template %q: %w", instanceTemplateName, err)
}
return instanceTemplate, nil
}
func instanceTemplateSourceImage(instanceTemplate *computepb.InstanceTemplate) (string, error) {
if instanceTemplate.Properties == nil ||
len(instanceTemplate.Properties.Disks) == 0 ||
instanceTemplate.Properties.Disks[0].InitializeParams == nil ||
instanceTemplate.Properties.Disks[0].InitializeParams.SourceImage == nil {
return "", errors.New("instance template has no source image")
}
return uriNormalize(*instanceTemplate.Properties.Disks[0].InitializeParams.SourceImage), nil
}

View file

@ -0,0 +1,284 @@
package client
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
"google.golang.org/protobuf/proto"
)
func TestGetScalingGroupImage(t *testing.T) {
testCases := map[string]struct {
scalingGroupID string
instanceGroupManagerTemplateID *string
instanceTemplate *computepb.InstanceTemplate
getInstanceGroupManagerErr error
getInstanceTemplateErr error
wantImage string
wantErr bool
}{
"getting image works": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image"),
},
},
},
},
},
wantImage: "projects/project/global/images/image",
},
"splitting scalingGroupID fails": {
scalingGroupID: "invalid",
wantErr: true,
},
"get instance fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
getInstanceGroupManagerErr: errors.New("get instance error"),
wantErr: true,
},
"instance group manager has no template": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
wantErr: true,
},
"instance group manager template id is invalid": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("invalid"),
wantErr: true,
},
"get instance template fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
getInstanceTemplateErr: errors.New("get instance template error"),
wantErr: true,
},
"instance template has no disks": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Properties: &computepb.InstanceProperties{},
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceGroupManagersAPI: &stubInstanceGroupManagersAPI{
getErr: tc.getInstanceGroupManagerErr,
instanceGroupManager: &computepb.InstanceGroupManager{
InstanceTemplate: tc.instanceGroupManagerTemplateID,
},
},
instanceTemplateAPI: &stubInstanceTemplateAPI{
getErr: tc.getInstanceTemplateErr,
template: tc.instanceTemplate,
},
}
gotImage, err := client.GetScalingGroupImage(context.Background(), tc.scalingGroupID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantImage, gotImage)
})
}
}
func TestSetScalingGroupImage(t *testing.T) {
testCases := map[string]struct {
scalingGroupID string
imageURI string
instanceGroupManagerTemplateID *string
instanceTemplate *computepb.InstanceTemplate
getInstanceGroupManagerErr error
getInstanceTemplateErr error
setInstanceTemplateErr error
insertInstanceTemplateErr error
wantErr bool
}{
"setting image works": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image-2",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Name: proto.String("instance-template"),
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image-1"),
},
},
},
},
},
},
"same image already in use": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Name: proto.String("instance-template"),
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image"),
},
},
},
},
},
// will not be triggered
insertInstanceTemplateErr: errors.New("insert instance template error"),
},
"splitting scalingGroupID fails": {
scalingGroupID: "invalid",
wantErr: true,
},
"get instance fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
getInstanceGroupManagerErr: errors.New("get instance error"),
wantErr: true,
},
"instance group manager has no template": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
wantErr: true,
},
"instance group manager template id is invalid": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("invalid"),
wantErr: true,
},
"get instance template fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
getInstanceTemplateErr: errors.New("get instance template error"),
wantErr: true,
},
"instance template has no disks": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Properties: &computepb.InstanceProperties{},
},
wantErr: true,
},
"instance template has no name": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image-2",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image-1"),
},
},
},
},
},
wantErr: true,
},
"instance template name generation fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image-2",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Name: proto.String("instance-template-999999999999999999999"),
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image-1"),
},
},
},
},
},
wantErr: true,
},
"instance template insert fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image-2",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Name: proto.String("instance-template"),
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image-1"),
},
},
},
},
},
insertInstanceTemplateErr: errors.New("insert instance template error"),
wantErr: true,
},
"setting instance template fails": {
scalingGroupID: "projects/project/zones/zone/instanceGroupManagers/instance-group",
imageURI: "projects/project/global/images/image-2",
instanceGroupManagerTemplateID: proto.String("projects/project/global/instanceTemplates/instance-template"),
instanceTemplate: &computepb.InstanceTemplate{
Name: proto.String("instance-template"),
Properties: &computepb.InstanceProperties{
Disks: []*computepb.AttachedDisk{
{
InitializeParams: &computepb.AttachedDiskInitializeParams{
SourceImage: proto.String("https://www.googleapis.com/compute/v1/projects/project/global/images/image-1"),
},
},
},
},
},
setInstanceTemplateErr: errors.New("setting instance template error"),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
client := Client{
instanceGroupManagersAPI: &stubInstanceGroupManagersAPI{
getErr: tc.getInstanceGroupManagerErr,
setInstanceTemplateErr: tc.setInstanceTemplateErr,
instanceGroupManager: &computepb.InstanceGroupManager{
InstanceTemplate: tc.instanceGroupManagerTemplateID,
},
},
instanceTemplateAPI: &stubInstanceTemplateAPI{
getErr: tc.getInstanceTemplateErr,
insertErr: tc.insertInstanceTemplateErr,
template: tc.instanceTemplate,
},
}
err := client.SetScalingGroupImage(context.Background(), tc.scalingGroupID, tc.imageURI)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
})
}
}