Files
threshold-plugin-gcp/testing/e2e_host_test.go
T
hashicorp-copywrite[bot] 722757e5c2 [COMPLIANCE] Add/Update Copyright Headers (#38)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-05-11 22:40:22 -07:00

590 lines
18 KiB
Go

// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-gcp/plugin/service/host"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
const (
expectedInstanceCount = 3
expectedTagsCount = 3
expectedLabelsCount = 3
)
// All the tests in this test depend on each other
// to run in order. This is because the tests are
// testing the same plugin instance and the same
// persisted state. The tests are designed to run
// in order to test the plugin's ability to rotate
// credentials and manage the state of the plugin
// instance.
// To run the tests you need the following
// environment variables set:
// GOOGLE_APPLICATION_CREDENTIALS: This should be
// the path to the service account key. This can also
// be automatically set after authenticating with gcloud
// using `gcloud auth application-default login`.
//
// TF_VAR_project_id: The project_id of the project.
//
// TF_VAR_region: The region the resources should be
// deployed in.
//
// TF_VAR_zone: The zone the resources should be
// deployed in.
func TestHostPlugin(t *testing.T) {
creds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
if creds == "" {
t.Skip("set GOOGLE_APPLICATION_CREDENTIALS to use this test")
}
projectId := os.Getenv("TF_VAR_project_id")
if projectId == "" {
t.Skip("set TF_VAR_project_id to use this test")
}
region := os.Getenv("TF_VAR_region")
if region == "" {
t.Skip("set TF_VAR_region to use this test")
}
zone := os.Getenv("TF_VAR_zone")
if zone == "" {
t.Skip("set TF_VAR_zone to use this test")
}
require := require.New(t)
tf, err := NewTestTerraformer("testdata/host")
require.NoError(err)
require.NotNil(tf)
t.Log("===== deploying test Terraform workspace =====")
err = tf.Deploy()
require.NoError(err)
defer func() {
t.Log("===== destroying test Terraform workspace =====")
if err := tf.Destroy(); err != nil {
t.Logf("WARNING: could not run Terraform destroy: %s", err)
}
}()
clientEmail, err := tf.GetOutput("client_email")
require.NoError(err)
require.NotNil(clientEmail)
instanceIds, err := tf.GetOutputSlice("instance_ids")
require.NoError(err)
require.Len(instanceIds, expectedInstanceCount)
instanceTags, err := tf.GetOutputMap("instance_tags")
require.NoError(err)
require.Len(instanceTags, expectedInstanceCount)
instanceLabels, err := tf.GetOutputMap("instance_labels")
require.NoError(err)
require.Len(instanceLabels, expectedLabelsCount)
privateKeys, err := tf.GetOutputSlice("private_keys")
require.NoError(err)
require.Len(privateKeys, expectedServiceAccountKeys)
privateKeyIds, err := tf.GetOutputSlice("private_key_ids")
require.NoError(err)
require.Len(privateKeyIds, expectedServiceAccountKeys)
p := new(host.HostPlugin)
ctx := context.Background()
var keyId, secret string
t.Run("OnCreateCatalog", func(t *testing.T) {
t.Run("Test non-rotation", func(t *testing.T) {
keyId, secret = testPluginOnCreateCatalog(
ctx,
t,
p,
zone,
projectId,
clientEmail.(string),
privateKeyIds[0].(string),
privateKeys[0].(string),
false,
)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Test rotation", func(t *testing.T) {
keyId, secret = testPluginOnCreateCatalog(
ctx,
t,
p,
zone,
projectId,
clientEmail.(string),
keyId,
secret,
true,
)
require.NotNil(keyId)
require.NotNil(secret)
})
})
t.Run("OnUpdateCatalog", func(t *testing.T) {
t.Run("Test no-op non-rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Test no-op with rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", true, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch credentials to next service account key. Don't rotate", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[1].(string), privateKeys[1].(string), true, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch credentials to next service account key. Add rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[2].(string), privateKeys[2].(string), false, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to next service account key, with rotation disabled", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[3].(string), privateKeys[3].(string), true, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to another service account key and keep rotation off", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[4].(string), privateKeys[4].(string), false, false)
require.NotNil(keyId)
require.NotNil(secret)
})
})
t.Run("OnDeleteCatalog", func(t *testing.T) {
t.Run("Test non-rotated", func(t *testing.T) {
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, false)
})
t.Run("Test as if we had rotated these credentials.", func(t *testing.T) {
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, true)
})
})
t.Run("HostSet", func(t *testing.T) {
keyId, secret := privateKeyIds[5].(string), privateKeys[5].(string)
expectedLabelInstancesMap := make(map[string][]string)
for instanceId, instanceLabel := range instanceLabels {
for tagKey := range instanceLabel.(map[string]any) {
for _, expectedTag := range instanceLabels {
if tagKey == expectedTag {
expectedLabelInstancesMap[tagKey] = append(expectedLabelInstancesMap[tagKey], instanceId)
}
}
}
}
cases := map[string][]string{
"label-1": {"foo"},
"label-2": {"bar"},
"label-3": {"baz"},
"label-4": {"foo", "bar"},
"label-5": {"foo", "baz"},
"label-6": {"bar", "baz"},
"label-7": {"foo", "bar", "baz"},
}
for key, tc := range cases {
t.Run(fmt.Sprintf("Label=%s", key), func(t *testing.T) {
labels := make([]string, 0, len(tc))
for _, label := range tc {
labels = append(labels, fmt.Sprintf("labels.%s=%s", key, label))
}
testPluginOnCreateUpdateSet(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels)
testPluginListHosts(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels, expectedLabelInstancesMap)
})
}
})
}
func testPluginOnCreateCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
rotate bool) (string, string) {
t.Helper()
t.Logf("testing OnCreateCatalog (zone=%s, rotate=%t)", zone, rotate)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstClientEmail: clientEmail,
credential.ConstProjectId: projectId,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), projectId, clientEmail, privateKeyId, privateKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
currentPrivateKeyId string,
currentPrivateKey string,
newPrivateKeyId string,
newPrivateKey string,
rotated bool,
rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (zone=%s, newcreds=%t, rotated=%t, rotate=%t)", zone, newPrivateKeyId != "" && newPrivateKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqNewAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
var reqSecrets *structpb.Struct
if newPrivateKeyId != "" && newPrivateKey != "" {
reqSecrets, err = structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: newPrivateKeyId,
credential.ConstPrivateKey: newPrivateKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: currentPrivateKeyId,
credential.ConstPrivateKey: currentPrivateKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(
t,
projectId,
clientEmail,
persisted.GetSecrets(),
currentCredsLastRotatedTime,
currentPrivateKeyId,
currentPrivateKey,
newPrivateKeyId,
newPrivateKey,
rotated,
rotate,
)
}
func testPluginOnDeleteCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
rotated bool) {
t.Helper()
t.Logf("testing OnDeleteCatalog (zone=%s, rotated=%t)", zone, rotated)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return time.Now().Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey)
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, projectId, clientEmail, privateKeyId, privateKey)
}
}
func testPluginOnCreateUpdateSet(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
labels []string) {
t.Helper()
t.Logf("testing OnCreateSet (zone=%s, labels=%v)", zone, labels)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstListInstancesFilter: []any{strings.Join(labels, " AND ")},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
createRequest := &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (zone=%s, labels=%v)", zone, labels)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
func testPluginListHosts(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
labels []string,
expected map[string][]string) {
t.Helper()
t.Logf("testing ListHosts (zone=%s, labels=%v)", zone, labels)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
sets := make([]*hostsets.HostSet, len(labels))
for i, label := range labels {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstListInstancesFilter: []any{label},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, label := range labels {
for _, instanceId := range expected[label] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", zone, labels, len(actualInstances), actualInstances)
}