refactor: organize plugin to support multiple services (#1)
Co-authored-by: Michael Li <michael.li@hashicorp.com>
This commit is contained in:
1 parent
c441058b3e
commit
46c53fb94a
31 files changed
+2951
-2446
No files matched your search
@@ -1,220 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func invalidArgumentError(msg string, f map[string]string) error {
|
||||
var fieldMsgs []string
|
||||
for field, val := range f {
|
||||
fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val))
|
||||
}
|
||||
if len(fieldMsgs) > 0 {
|
||||
sort.Strings(fieldMsgs)
|
||||
msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", "))
|
||||
}
|
||||
return status.Error(codes.InvalidArgument, msg)
|
||||
}
|
||||
|
||||
// CatalogAttributes is a Go-native representation of the Attributes
|
||||
// map.
|
||||
type CatalogAttributes struct {
|
||||
Region string
|
||||
DisableCredentialRotation bool
|
||||
}
|
||||
|
||||
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||
unknownFields := structFields(in)
|
||||
result := new(CatalogAttributes)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
var err error
|
||||
result.Region, err = getStringValue(in, constRegion, true)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", constRegion)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, constRegion)
|
||||
|
||||
result.DisableCredentialRotation, err = getBoolValue(in, constDisableCredentialRotation, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", constDisableCredentialRotation)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, constDisableCredentialRotation)
|
||||
|
||||
for s := range unknownFields {
|
||||
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, invalidArgumentError("Invalid arguments in catalog attributes", badFields)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CatalogSecrets is a Go-native representation of the Secrets map.
|
||||
type CatalogSecrets struct {
|
||||
AccessKeyId string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
func getCatalogSecrets(in *structpb.Struct) (*CatalogSecrets, error) {
|
||||
unknownFields := structFields(in)
|
||||
result := new(CatalogSecrets)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
var err error
|
||||
result.AccessKeyId, err = getStringValue(in, constAccessKeyId, true)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", constAccessKeyId)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, constAccessKeyId)
|
||||
|
||||
result.SecretAccessKey, err = getStringValue(in, constSecretAccessKey, true)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", constSecretAccessKey)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, constSecretAccessKey)
|
||||
|
||||
for s := range unknownFields {
|
||||
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, invalidArgumentError("Error in the secrets provided", badFields)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetAttributes is a Go-native representation of the Attributes map that can be
|
||||
// used for decoding the incoming map via mapstructure.
|
||||
type SetAttributes struct {
|
||||
Filters []string
|
||||
}
|
||||
|
||||
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
var setAttrs SetAttributes
|
||||
|
||||
// Quick validation to ensure that there's no non-filter attributes
|
||||
// here for now. Make this more complex if we add more attributes
|
||||
// to host sets.
|
||||
unknownFields := structFields(in)
|
||||
badFields := make(map[string]string)
|
||||
delete(unknownFields, constDescribeInstancesFilters)
|
||||
for a := range unknownFields {
|
||||
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
|
||||
}
|
||||
if len(badFields) > 0 {
|
||||
return nil, invalidArgumentError("Error in the attributes provided", badFields)
|
||||
}
|
||||
|
||||
// Mapstructure complains if it expects a slice as output and sees a scalar
|
||||
// value. Rather than use WeakDecode and risk unintended consequences, I'm
|
||||
// manually making this change if necessary.
|
||||
inMap := in.AsMap()
|
||||
if filtersRaw, ok := inMap[constDescribeInstancesFilters]; ok {
|
||||
switch filterVal := filtersRaw.(type) {
|
||||
case string:
|
||||
inMap[constDescribeInstancesFilters] = []string{filterVal}
|
||||
}
|
||||
}
|
||||
|
||||
if err := mapstructure.Decode(inMap, &setAttrs); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
||||
}
|
||||
|
||||
return &setAttrs, nil
|
||||
}
|
||||
|
||||
func getStringValue(in *structpb.Struct, k string, required bool) (string, error) {
|
||||
mv := in.GetFields()
|
||||
v, ok := mv[k]
|
||||
if !ok {
|
||||
if required {
|
||||
return "", fmt.Errorf("missing required value %q", k)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
s, ok := v.AsInterface().(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
||||
}
|
||||
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("value %q cannot be empty", k)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func getBoolValue(in *structpb.Struct, k string, required bool) (bool, error) {
|
||||
mv := in.GetFields()
|
||||
v, ok := mv[k]
|
||||
if !ok {
|
||||
if required {
|
||||
return false, fmt.Errorf("missing required value %q", k)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
b, ok := v.AsInterface().(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface())
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func getTimeValue(in *structpb.Struct, k string) (time.Time, error) {
|
||||
mv := in.GetFields()
|
||||
v, ok := mv[k]
|
||||
if !ok {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
tRaw, ok := v.AsInterface().(string)
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339Nano, tRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func structFields(s *structpb.Struct) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(s.GetFields()))
|
||||
for k := range s.GetFields() {
|
||||
m[k] = struct{}{}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func keysAsString(m map[string]struct{}) string {
|
||||
s := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
s = append(s, k)
|
||||
}
|
||||
|
||||
sort.Strings(s)
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestGetCatalogAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
expected *CatalogAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing region",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
expectedErrContains: "missing required value \"region\"",
|
||||
},
|
||||
{
|
||||
name: "bad value for disable_credential_rotation",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constRegion: "us-west-2",
|
||||
constDisableCredentialRotation: "sure",
|
||||
}),
|
||||
expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constRegion: "us-west-2",
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
}),
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constRegion: "us-west-2",
|
||||
}),
|
||||
expected: &CatalogAttributes{
|
||||
Region: "us-west-2",
|
||||
DisableCredentialRotation: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with disable_credential_rotation",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constRegion: "us-west-2",
|
||||
constDisableCredentialRotation: true,
|
||||
}),
|
||||
expected: &CatalogAttributes{
|
||||
Region: "us-west-2",
|
||||
DisableCredentialRotation: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getCatalogAttributes(tc.in)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCatalogSecrets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
expected *CatalogSecrets
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing access_key_id",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
expectedErrContains: "missing required value \"access_key_id\"",
|
||||
},
|
||||
{
|
||||
name: "missing secret_access_key",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
}),
|
||||
expectedErrContains: "missing required value \"secret_access_key\"",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
}),
|
||||
expectedErrContains: "secrets.bar: unrecognized field, secrets.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
}),
|
||||
expected: &CatalogSecrets{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getCatalogSecrets(tc.in)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
normalized *structpb.Struct
|
||||
expected *SetAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
expected: &SetAttributes{},
|
||||
},
|
||||
{
|
||||
name: "non-string-slice value",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: "zip=foo,bar",
|
||||
}),
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"zip=foo,bar"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad filter element value",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: []interface{}{1},
|
||||
}),
|
||||
expectedErrContains: "expected type 'string', got unconvertible type 'float64'",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
}),
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: []interface{}{
|
||||
"foo=bar",
|
||||
"zip=zap",
|
||||
},
|
||||
}),
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"foo=bar", "zip=zap"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good with filter transform",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: "foo=bar",
|
||||
}),
|
||||
normalized: mustStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: []interface{}{"foo=bar"},
|
||||
}),
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"foo=bar"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
p := new(AwsPlugin)
|
||||
normalizedOut, err := p.NormalizeSetData(context.Background(), &plugin.NormalizeSetDataRequest{Attributes: tc.in})
|
||||
require.NoError(err)
|
||||
if tc.normalized != nil {
|
||||
require.Empty(cmp.Diff(tc.normalized, normalizedOut.Attributes, protocmp.Transform()))
|
||||
}
|
||||
tc.in = normalizedOut.Attributes
|
||||
actual, err := getSetAttributes(tc.in)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStringValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
key string
|
||||
required bool
|
||||
expected string
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "required missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
key: "foo",
|
||||
required: true,
|
||||
expectedErr: "missing required value \"foo\"",
|
||||
},
|
||||
{
|
||||
name: "optional missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
key: "foo",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-string value",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": 1,
|
||||
}),
|
||||
key: "foo",
|
||||
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
||||
},
|
||||
{
|
||||
name: "required empty",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": "",
|
||||
}),
|
||||
key: "foo",
|
||||
required: true,
|
||||
expectedErr: "value \"foo\" cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": "bar",
|
||||
}),
|
||||
key: "foo",
|
||||
expected: "bar",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getStringValue(tc.in, tc.key, tc.required)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBoolValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
key string
|
||||
required bool
|
||||
expected bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "required missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
key: "foo",
|
||||
required: true,
|
||||
expectedErr: "missing required value \"foo\"",
|
||||
},
|
||||
{
|
||||
name: "optional missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
key: "foo",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "non-bool value",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": "bar",
|
||||
}),
|
||||
key: "foo",
|
||||
expectedErr: "unexpected type for value \"foo\": want bool, got string",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": true,
|
||||
}),
|
||||
key: "foo",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getBoolValue(tc.in, tc.key, tc.required)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTimeValue(t *testing.T) {
|
||||
staticTime := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
key string
|
||||
expected time.Time
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
in: mustStruct(map[string]interface{}{}),
|
||||
key: "foo",
|
||||
expected: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "non-time value",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": 1,
|
||||
}),
|
||||
key: "foo",
|
||||
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
||||
},
|
||||
{
|
||||
name: "bad parse",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": "bar",
|
||||
}),
|
||||
key: "foo",
|
||||
expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: mustStruct(map[string]interface{}{
|
||||
"foo": staticTime.Format(time.RFC3339Nano),
|
||||
}),
|
||||
key: "foo",
|
||||
expected: func() time.Time {
|
||||
u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return u
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getTimeValue(tc.in, tc.key)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid region",
|
||||
in: "foobar",
|
||||
expectedErr: "not a valid region: foobar",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: "us-west-2",
|
||||
expectedErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
err := validateRegion(tc.in)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
const (
|
||||
constRegion = "region"
|
||||
constAccessKeyId = "access_key_id"
|
||||
constSecretAccessKey = "secret_access_key"
|
||||
constDisableCredentialRotation = "disable_credential_rotation"
|
||||
constCredsLastRotatedTime = "creds_last_rotated_time"
|
||||
constDescribeInstancesFilters = "filters"
|
||||
)
|
||||
@@ -1,638 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
pluginTesting "github.com/hashicorp/boundary-plugin-host-aws/testing"
|
||||
"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/hashicorp/go-secure-stdlib/awsutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
expectedIamUserCount = 6
|
||||
expectedEc2InstanceCount = 5
|
||||
)
|
||||
|
||||
var expectedTags = []string{"foo", "bar", "baz"}
|
||||
|
||||
func TestPlugin(t *testing.T) {
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
t.Skip("set AWS_REGION to use this test")
|
||||
}
|
||||
if envAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID"); envAccessKeyId == "" {
|
||||
t.Skip("set AWS_ACCESS_KEY_ID to use this test")
|
||||
}
|
||||
if envSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY"); envSecretAccessKey == "" {
|
||||
t.Skip("set AWS_SECRET_ACCESS_KEY to use this test")
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
tf, err := pluginTesting.NewTestTerraformer("testdata/basic")
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
iamUserNames, err := tf.GetOutputSlice("iam_user_names")
|
||||
require.NoError(err)
|
||||
require.Len(iamUserNames, expectedIamUserCount)
|
||||
|
||||
iamUserArns, err := tf.GetOutputSlice("iam_user_arns")
|
||||
require.NoError(err)
|
||||
require.Len(iamUserArns, expectedIamUserCount)
|
||||
|
||||
iamAccessKeyIds, err := tf.GetOutputSlice("iam_access_key_ids")
|
||||
require.NoError(err)
|
||||
require.Len(iamAccessKeyIds, expectedIamUserCount)
|
||||
|
||||
iamSecretAccessKeys, err := tf.GetOutputSlice("iam_secret_access_keys")
|
||||
require.NoError(err)
|
||||
require.Len(iamSecretAccessKeys, expectedIamUserCount)
|
||||
|
||||
ec2InstanceIds, err := tf.GetOutputSlice("instance_ids")
|
||||
require.NoError(err)
|
||||
require.Len(ec2InstanceIds, expectedEc2InstanceCount)
|
||||
|
||||
ec2InstanceAddrs, err := tf.GetOutputMap("instance_addrs")
|
||||
require.NoError(err)
|
||||
require.Len(ec2InstanceAddrs, expectedEc2InstanceCount)
|
||||
|
||||
ec2InstanceTags, err := tf.GetOutputMap("instance_tags")
|
||||
require.NoError(err)
|
||||
require.Len(ec2InstanceTags, expectedEc2InstanceCount)
|
||||
|
||||
// Start the workflow now. Set up the host catalog. Note that this
|
||||
// will cause the state to go out of drift above in the sense that
|
||||
// the access key ID/secret access key will no longer be valid. We
|
||||
// will assert this through the returned state.
|
||||
p := new(AwsPlugin)
|
||||
ctx := context.Background()
|
||||
|
||||
// ********************
|
||||
// * OnCreateCatalog
|
||||
// ********************
|
||||
//
|
||||
// Test non-rotation (using primary user).
|
||||
keyid, secret := testPluginOnCreateCatalog(ctx, t, p, region, iamAccessKeyIds[0].(string), iamSecretAccessKeys[0].(string), false)
|
||||
// Test rotation next.
|
||||
keyid, secret = testPluginOnCreateCatalog(ctx, t, p, region, keyid, secret, true)
|
||||
|
||||
// ********************
|
||||
// * OnUpdateCatalog
|
||||
// ********************
|
||||
//
|
||||
// Test no-op non-rotation.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, false)
|
||||
// Switch to rotation.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, true)
|
||||
// Test no-op with rotation.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", true, true)
|
||||
// Switch credentials to next user. Don't rotate.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[1].(string), iamSecretAccessKeys[1].(string), true, false)
|
||||
// Switch credentials to next user. Add rotation.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[2].(string), iamSecretAccessKeys[2].(string), false, true)
|
||||
// Switch to next user, with rotation disabled.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[3].(string), iamSecretAccessKeys[3].(string), true, false)
|
||||
// Last case - switch to another user and keep rotation off.
|
||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[4].(string), iamSecretAccessKeys[4].(string), false, false)
|
||||
|
||||
// ********************
|
||||
// * OnDeleteCatalog
|
||||
// ********************
|
||||
//
|
||||
// Test non-rotated.
|
||||
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, false)
|
||||
// Test as if we had rotated these credentials (note that this
|
||||
// makes this test set unusable).
|
||||
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, true)
|
||||
|
||||
// ********************
|
||||
// * Host set stuff
|
||||
// ********************
|
||||
// Reassign the keyid and secret first.
|
||||
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
|
||||
// Process the collection of instances and index by expected tag names.
|
||||
expectedTagInstancesMap := make(map[string][]string)
|
||||
for instanceId, instanceTags := range ec2InstanceTags {
|
||||
for tagKey := range instanceTags.(map[string]interface{}) {
|
||||
for _, expectedTag := range expectedTags {
|
||||
if tagKey == expectedTag {
|
||||
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{"foo"},
|
||||
{"bar"},
|
||||
{"baz"},
|
||||
{"foo", "bar"},
|
||||
{"foo", "baz"},
|
||||
{"bar", "baz"},
|
||||
{"foo", "bar", "baz"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
// Test create/update in one step
|
||||
testPluginOnCreateUpdateSet(ctx, t, p, region, keyid, secret, tc)
|
||||
// Test ListHosts
|
||||
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
|
||||
// TODO: add OnDeleteSet if it needs to be implemented
|
||||
}
|
||||
}
|
||||
|
||||
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate)
|
||||
require := require.New(t)
|
||||
|
||||
reqAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: !rotate,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: accessKeyId,
|
||||
constSecretAccessKey: secretAccessKey,
|
||||
})
|
||||
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)
|
||||
persistedSecrets := persisted.GetSecrets()
|
||||
require.NotNil(persistedSecrets)
|
||||
persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedAccessKeyId)
|
||||
if rotate {
|
||||
require.NotEqual(accessKeyId, persistedAccessKeyId)
|
||||
} else {
|
||||
require.Equal(accessKeyId, persistedAccessKeyId)
|
||||
}
|
||||
|
||||
persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedSecretAccessKey)
|
||||
if rotate {
|
||||
require.NotEqual(secretAccessKey, persistedSecretAccessKey)
|
||||
} else {
|
||||
require.Equal(secretAccessKey, persistedSecretAccessKey)
|
||||
}
|
||||
|
||||
persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime)
|
||||
require.NoError(err)
|
||||
if rotate {
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
|
||||
} else {
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
}
|
||||
|
||||
return persistedAccessKeyId, persistedSecretAccessKey
|
||||
}
|
||||
|
||||
func testPluginOnUpdateCatalog(
|
||||
ctx context.Context, t *testing.T, p *AwsPlugin,
|
||||
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
|
||||
rotated, rotate bool,
|
||||
) (string, string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", 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]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: !rotated,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqNewAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: !rotate,
|
||||
})
|
||||
require.NoError(err)
|
||||
var reqSecrets *structpb.Struct
|
||||
if newAccessKeyId != "" && newSecretAccessKey != "" {
|
||||
reqSecrets, err = structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: newAccessKeyId,
|
||||
constSecretAccessKey: newSecretAccessKey,
|
||||
})
|
||||
require.NoError(err)
|
||||
}
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: currentAccessKeyId,
|
||||
constSecretAccessKey: currentSecretAccessKey,
|
||||
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)
|
||||
persistedSecrets := persisted.GetSecrets()
|
||||
require.NotNil(persistedSecrets)
|
||||
|
||||
// Complex checks based on the scenarios.
|
||||
persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedAccessKeyId)
|
||||
persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedSecretAccessKey)
|
||||
persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime)
|
||||
require.NoError(err)
|
||||
|
||||
// Our test scenarios are complex due the multi-dimensional nature
|
||||
// of criteria, so we lay them out in a switch below.
|
||||
switch {
|
||||
case newAccessKeyId != "" && rotated && rotate:
|
||||
// The new access key ID was provided, we had previously rotated
|
||||
// the credentials before, and the new credential set is to be
|
||||
// rotated as well. In this case, the old credentials should have
|
||||
// been deleted, and the new credentials should have been rotated,
|
||||
// hence, should not match the new credentials initially
|
||||
// provided. Rotation time should be non-zero and updated.
|
||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
||||
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||
|
||||
case newAccessKeyId != "" && rotated && !rotate:
|
||||
// The new access key ID was provided, we had previously rotated
|
||||
// the credentials before, and the new credential is *not*
|
||||
// rotated. In this case, the old credentials should have
|
||||
// been deleted, But the new credentials should have not been
|
||||
// rotated, and hence should be the same. Rotation time should be
|
||||
// zero.
|
||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
||||
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newAccessKeyId != "" && !rotated && rotate:
|
||||
// The new access key ID was provided, we *have not* previously
|
||||
// rotated the credentials, and the new credential set is to be
|
||||
// rotated. In this case, the old credentials should have been
|
||||
// left alone, and the new credentials should have been rotated,
|
||||
// hence, should not match the new credentials initially
|
||||
// provided. Rotation time should be non-zero, but updated.
|
||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
||||
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||
|
||||
case newAccessKeyId != "" && !rotated && !rotate:
|
||||
// The new access key ID was provided, but we have not rotated
|
||||
// the credentials previously and we still don't plan on rotating
|
||||
// them. In this case, the old credentials should still be valid,
|
||||
// and the persisted ones should match the new ones provided.
|
||||
// Rotation time should be zero.
|
||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
||||
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newAccessKeyId == "" && rotated && rotate:
|
||||
// No new credentials have been provided, but we have previously
|
||||
// rotated and are still rotating credentials. This is a no-op.
|
||||
// Existing credentials should still be valid and match the ones
|
||||
// persisted to state. Rotation time should be identical since
|
||||
// no new rotation occurred.
|
||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
||||
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime))
|
||||
|
||||
case newAccessKeyId == "" && rotated && !rotate:
|
||||
// No new credentials have been provided, and we have previously
|
||||
// rotated the credentials. This is actually an error, but we
|
||||
// don't test it here; it's covered in unit testing (see
|
||||
// plugin_test.go).
|
||||
require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper")
|
||||
|
||||
case newAccessKeyId == "" && !rotated && rotate:
|
||||
// No new credentials have been provided, and while we did not
|
||||
// rotate before, we want to switch to rotation. In this case,
|
||||
// the existing persisted credentials should have been rotated,
|
||||
// with a new non-zero timestamp.
|
||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.NotEqual(persistedAccessKeyId, currentAccessKeyId)
|
||||
require.NotEqual(persistedSecretAccessKey, currentSecretAccessKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newAccessKeyId == "" && !rotated && !rotate:
|
||||
// No new credentials have been provided and we have not, nor do
|
||||
// not, plan on rotating the credentials. This is a no-op.
|
||||
// Existing credentials should still be valid and match the ones
|
||||
// persisted to state. Rotation time should remain at zero.
|
||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
||||
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
default:
|
||||
// Scenario was reached that was not covered by this function.
|
||||
require.FailNow("unknown test scenario")
|
||||
}
|
||||
|
||||
return persistedAccessKeyId, persistedSecretAccessKey
|
||||
}
|
||||
|
||||
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, rotated bool) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated)
|
||||
require := require.New(t)
|
||||
|
||||
reqAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: !rotated,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: accessKeyId,
|
||||
constSecretAccessKey: secretAccessKey,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: accessKeyId,
|
||||
constSecretAccessKey: secretAccessKey,
|
||||
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, accessKeyId, secretAccessKey)
|
||||
} 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, accessKeyId, secretAccessKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *AwsPlugin, region, accessKeyId, secretAccessKey string, tags []string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags)
|
||||
require := require.New(t)
|
||||
catalogAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||
})
|
||||
require.NoError(err)
|
||||
setAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
|
||||
})
|
||||
require.NoError(err)
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: accessKeyId,
|
||||
constSecretAccessKey: secretAccessKey,
|
||||
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 (region=%s, tags=%v)", region, tags)
|
||||
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 *AwsPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) {
|
||||
t.Helper()
|
||||
t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags)
|
||||
require := require.New(t)
|
||||
catalogAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constRegion: region,
|
||||
constDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||
})
|
||||
require.NoError(err)
|
||||
sets := make([]*hostsets.HostSet, len(tags))
|
||||
for i, tag := range tags {
|
||||
setAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||
constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", tag)},
|
||||
})
|
||||
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]interface{}{
|
||||
constAccessKeyId: accessKeyId,
|
||||
constSecretAccessKey: secretAccessKey,
|
||||
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, tag := range tags {
|
||||
for _, instanceId := range expected[tag] {
|
||||
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))", region, tags, len(actualInstances), actualInstances)
|
||||
}
|
||||
|
||||
func requireCredentialsInvalid(t *testing.T, accessKeyId, secretAccessKey string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
c, err := awsutil.NewCredentialsConfig(
|
||||
awsutil.WithAccessKey(accessKeyId),
|
||||
awsutil.WithSecretKey(secretAccessKey),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
// We need to wait for invalidation as while awsutil waits for
|
||||
// credential creation, deletion of the old credentials returns
|
||||
// immediately.
|
||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
defer cancel()
|
||||
waitErr:
|
||||
for {
|
||||
_, err = c.GetCallerIdentity()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
// pass
|
||||
|
||||
case <-timeoutCtx.Done():
|
||||
break waitErr
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(err)
|
||||
awsErr, ok := err.(awserr.Error)
|
||||
require.True(ok)
|
||||
require.Equal("InvalidClientTokenId", awsErr.Code())
|
||||
}
|
||||
|
||||
func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
c, err := awsutil.NewCredentialsConfig(
|
||||
awsutil.WithAccessKey(accessKeyId),
|
||||
awsutil.WithSecretKey(secretAccessKey),
|
||||
)
|
||||
require.NoError(err)
|
||||
_, err = c.GetCallerIdentity()
|
||||
require.NoError(err)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
// getOpts iterates the inbound Options and returns a struct
|
||||
func getOpts(opt ...Option) (options, error) {
|
||||
opts := getDefaultOptions()
|
||||
for _, o := range opt {
|
||||
if o == nil {
|
||||
continue
|
||||
}
|
||||
if err := o(&opts); err != nil {
|
||||
return options{}, err
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// Option - how Options are passed as arguments
|
||||
type Option func(*options) error
|
||||
|
||||
// options = how options are represented
|
||||
type options struct {
|
||||
withRegion string
|
||||
}
|
||||
|
||||
func getDefaultOptions() options {
|
||||
return options{}
|
||||
}
|
||||
|
||||
// WithRegion contains the region to use
|
||||
func WithRegion(with string) Option {
|
||||
return func(o *options) error {
|
||||
o.withRegion = with
|
||||
return nil
|
||||
}
|
||||
}
|
||||
+10
-685
@@ -1,695 +1,20 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/endpoints"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/plugin/service/host"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
// rotationWaitTimeout controls the time we wait for credential rotation to
|
||||
// succeed. This is important to ensure that rotated credentials can be used
|
||||
// right away.
|
||||
rotationWaitTimeout = time.Second * 30
|
||||
|
||||
// instanceNameTagKey denotes the special AWS tag key that contains an EC2
|
||||
// instance's name. This field is to be matched literally.
|
||||
instanceNameTagKey = "Name"
|
||||
)
|
||||
|
||||
// AwsPlugin implements the HostPluginServiceServer interface for the
|
||||
// AWS plugin.
|
||||
type AwsPlugin struct {
|
||||
pb.UnimplementedHostPluginServiceServer
|
||||
|
||||
// testStateOpts are passed in to the stored state to control test behavior
|
||||
testStateOpts []awsCatalogPersistedStateOption
|
||||
}
|
||||
|
||||
// Ensure that we are implementing HostPluginServiceServer
|
||||
// Ensure that AWSPlugin implementings the following services:
|
||||
//
|
||||
// HostPluginServiceServer
|
||||
var (
|
||||
_ pb.HostPluginServiceServer = (*AwsPlugin)(nil)
|
||||
_ pb.HostPluginServiceServer = (*host.HostPlugin)(nil)
|
||||
)
|
||||
|
||||
func (p *AwsPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
secrets := catalog.GetSecrets()
|
||||
if secrets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "secrets are required")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalogSecrets, err := getCatalogSecrets(secrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set up the creds in our persisted state.
|
||||
state, err := newAwsCatalogPersistedState(append([]awsCatalogPersistedStateOption{
|
||||
withAccessKeyId(catalogSecrets.AccessKeyId),
|
||||
withSecretAccessKey(catalogSecrets.SecretAccessKey),
|
||||
withRegion(catalogAttributes.Region),
|
||||
}, p.testStateOpts...)...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
|
||||
}
|
||||
|
||||
// Try to rotate the credentials if we're not skipping them.
|
||||
if !catalogAttributes.DisableCredentialRotation {
|
||||
if err := state.RotateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||
}
|
||||
} else {
|
||||
// Simply validate if we aren't rotating.
|
||||
if err := state.ValidateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
persistedProto, err := state.ToProto()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &pb.OnCreateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
||||
// For host catalogs we only need to really validate what is
|
||||
// currently in the new copy of the host catalog data, so skip
|
||||
// fetching the new copy to keep things a little less messy.
|
||||
catalog := req.GetNewCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
var updateSecrets bool
|
||||
secrets := catalog.GetSecrets()
|
||||
if secrets != nil {
|
||||
// We will be updating secrets this run, but what exactly that
|
||||
// means will be determined later.
|
||||
updateSecrets = true
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the persisted data.
|
||||
// NOTE: We might need to change this at a later time. I'm not too
|
||||
// sure *exactly* what scenarios we might encounter that would
|
||||
// ultimately mean that we would have to handle an empty or missing
|
||||
// state in update, but what we are ultimately assuming here
|
||||
// (implicitly through awsCatalogPersistedStateFromProto) is that
|
||||
// the state will exist and be populated. Personally I think this
|
||||
// is fine and important, but this may change in the future.
|
||||
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
if catalogAttributes.DisableCredentialRotation && !updateSecrets {
|
||||
// This is a validate check to make sure that we aren't disabling
|
||||
// rotation for credentials currently being managed by rotation.
|
||||
// This is not allowed.
|
||||
if !state.CredsLastRotatedTime.IsZero() {
|
||||
return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials")
|
||||
}
|
||||
}
|
||||
|
||||
if updateSecrets {
|
||||
catalogSecrets, err := getCatalogSecrets(secrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Replace the credentials. This checks the timestamp on the last
|
||||
// rotation time as well and deletes the credentials if we are
|
||||
// managing them (ie: if we've rotated them before).
|
||||
if err := state.ReplaceCreds(catalogSecrets.AccessKeyId, catalogSecrets.SecretAccessKey); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !catalogAttributes.DisableCredentialRotation && state.CredsLastRotatedTime.IsZero() {
|
||||
// If we're enabling rotation now but didn't before, or have
|
||||
// freshly replaced credentials, we can rotate here.
|
||||
if err := state.RotateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// That's it!
|
||||
persistedProto, err := state.ToProto()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &pb.OnUpdateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get the persisted data.
|
||||
// NOTE: We return on error here, blocking the delete. This may or
|
||||
// may not be an overzealous approach to maintaining database/state
|
||||
// integrity. May need to be changed at later time if there are
|
||||
// scenarios where we might be deleting things and any secret state
|
||||
// may be corrupt/and or legitimately missing.
|
||||
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
if !state.CredsLastRotatedTime.IsZero() {
|
||||
// Delete old/existing credentials. This is done with the same
|
||||
// credentials to ensure that it has the proper permissions to do
|
||||
// it.
|
||||
if err := state.DeleteCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.OnDeleteCatalogResponse{}, nil
|
||||
}
|
||||
|
||||
// NormalizeSetData currently ensures that "filters" is an array value, even
|
||||
// though it's accepted as a string value for CLI UX reasons
|
||||
func (p *AwsPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
|
||||
if req.Attributes == nil {
|
||||
return new(pb.NormalizeSetDataResponse), nil
|
||||
}
|
||||
|
||||
val := req.Attributes.Fields["filters"]
|
||||
if val == nil {
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
stringVal, ok := val.Kind.(*structpb.Value_StringValue)
|
||||
if !ok {
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
|
||||
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
|
||||
retAttrs.Fields["filters"] = structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue(stringVal.StringValue),
|
||||
},
|
||||
})
|
||||
|
||||
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
set := req.GetSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set is nil")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ec2Client, err := state.EC2Client(WithRegion(catalogAttributes.Region))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||
}
|
||||
|
||||
_, err = ec2Client.DescribeInstances(input)
|
||||
if err == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||
}
|
||||
|
||||
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||
// Success
|
||||
return &pb.OnCreateSetResponse{}, nil
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
// As with catalog, we don't need to really look at the old host
|
||||
// set here, just need to work off of/validate the new set config
|
||||
set := req.GetNewSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new set is nil")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ec2Client, err := state.EC2Client(WithRegion(catalogAttributes.Region))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||
}
|
||||
|
||||
_, err = ec2Client.DescribeInstances(input)
|
||||
if err == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||
}
|
||||
|
||||
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||
// Success
|
||||
return &pb.OnUpdateSetResponse{}, nil
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
||||
// No-op, AWS host set does not maintain anything that requires
|
||||
// cleanup
|
||||
return &pb.OnDeleteSetResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *AwsPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), append(p.testStateOpts, withRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
sets := req.GetSets()
|
||||
if sets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "sets is nil")
|
||||
}
|
||||
|
||||
// Build all the queries in advance.
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
Input *ec2.DescribeInstancesInput
|
||||
Output *ec2.DescribeInstancesOutput
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
queries := make([]hostSetQuery, len(sets))
|
||||
for i, set := range sets {
|
||||
// Validate Id since we use it in output
|
||||
if set.GetId() == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing id")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, false)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
|
||||
}
|
||||
queries[i] = hostSetQuery{
|
||||
Id: set.GetId(),
|
||||
Input: input,
|
||||
}
|
||||
}
|
||||
|
||||
ec2Client, err := state.EC2Client()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
// Run all queries now and assemble output.
|
||||
var maxLen int
|
||||
for i, query := range queries {
|
||||
output, err := ec2Client.DescribeInstances(query.Input)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
|
||||
// Process the output here, we will normalize this into a single
|
||||
// set of hosts afterwards (possibly removing duplicates).
|
||||
for _, reservation := range output.Reservations {
|
||||
for _, instance := range reservation.Instances {
|
||||
host, err := awsInstanceToHost(instance)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||
maxLen++ // Increment maximum counter for allocation later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now de-duplicate the hosts for the output. Maintain two sets:
|
||||
// * A slice of hosts that will be used in the output
|
||||
// * A map of hosts indexed by their external ID
|
||||
//
|
||||
// The map will is used in de-duplication to determine whether or
|
||||
// not we've seen the host before to simply add the query's set ID
|
||||
// to the list of set IDs that the host was seen in.
|
||||
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
||||
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
||||
for _, query := range queries {
|
||||
for _, host := range query.OutputHosts {
|
||||
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
||||
// Existing host, just add the set ID to the list of seen IDs
|
||||
// and continue
|
||||
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// This will be the first seen entry, so append the set ID to
|
||||
// this host, and add it.
|
||||
host.SetIds = append(host.SetIds, query.Id)
|
||||
hostResultSlice = append(hostResultSlice, host)
|
||||
hostResultMap[host.ExternalId] = host
|
||||
}
|
||||
}
|
||||
|
||||
// Done!
|
||||
return &pb.ListHostsResponse{
|
||||
Hosts: hostResultSlice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateRegion(region string) error {
|
||||
_, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region)
|
||||
if !found {
|
||||
return status.Errorf(codes.InvalidArgument, "not a valid region: %s", region)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFilters(attrs *SetAttributes) ([]*ec2.Filter, error) {
|
||||
var filters []*ec2.Filter
|
||||
var foundStateFilter bool
|
||||
for _, filterAttr := range attrs.Filters {
|
||||
// Each filter arrives in "k=v1,v2" format. First, split on equal sign.
|
||||
splitFilter := strings.Split(filterAttr, "=")
|
||||
switch {
|
||||
case len(splitFilter) != 2:
|
||||
return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr)
|
||||
case len(splitFilter[0]) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
|
||||
case len(splitFilter[1]) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
|
||||
}
|
||||
|
||||
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
||||
if filterKey == "instance-state-code" || filterKey == "instance-state-name" {
|
||||
foundStateFilter = true
|
||||
}
|
||||
|
||||
var filterValues []*string
|
||||
for _, val := range strings.Split(filterValue, ",") {
|
||||
filterValues = append(filterValues, aws.String(val))
|
||||
}
|
||||
|
||||
filters = append(filters, &ec2.Filter{
|
||||
Name: aws.String(filterKey),
|
||||
Values: filterValues,
|
||||
})
|
||||
}
|
||||
|
||||
if !foundStateFilter {
|
||||
// if there is no explicit instance state filter, add the
|
||||
// instance-state-name = ["running"] to the filter set. This
|
||||
// ensures that we filter on running instances only at the API
|
||||
// side, saving time when processing results.
|
||||
filters = append(filters, &ec2.Filter{
|
||||
Name: aws.String("instance-state-name"),
|
||||
Values: aws.StringSlice([]string{ec2.InstanceStateNameRunning}),
|
||||
})
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
||||
filters, err := buildFilters(attrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building filters: %w", err)
|
||||
}
|
||||
|
||||
return &ec2.DescribeInstancesInput{
|
||||
DryRun: aws.Bool(dryRun),
|
||||
Filters: filters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// awsInstanceToHost processes data from an ec2.Instance and returns
|
||||
// a ListHostsResponseHost with the ID and network addresses
|
||||
// populated.
|
||||
func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) {
|
||||
// Integrity check: some fields should always be non-nil. Check
|
||||
// those.
|
||||
if instance == nil {
|
||||
return nil, errors.New("response integrity error: missing instance entry")
|
||||
}
|
||||
|
||||
if aws.StringValue(instance.InstanceId) == "" {
|
||||
return nil, errors.New("response integrity error: missing instance id")
|
||||
}
|
||||
|
||||
result := new(pb.ListHostsResponseHost)
|
||||
|
||||
// External ID is the instance ID.
|
||||
result.ExternalId = aws.StringValue(instance.InstanceId)
|
||||
|
||||
// External Name is the AWS "Name" tag, if it exists.
|
||||
for _, t := range instance.Tags {
|
||||
if aws.StringValue(t.Key) == instanceNameTagKey {
|
||||
result.ExternalName = aws.StringValue(t.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// First IP address/dns name are always the private fields if they
|
||||
// are populated
|
||||
if aws.StringValue(instance.PrivateIpAddress) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(instance.PrivateDnsName) != "" {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Public IP address/dns names are next
|
||||
if aws.StringValue(instance.PublicIpAddress) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress))
|
||||
}
|
||||
if aws.StringValue(instance.PublicDnsName) != "" {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PublicDnsName))
|
||||
}
|
||||
|
||||
// Now go through all of the interfaces and log the IP address of
|
||||
// every interface.
|
||||
for _, iface := range instance.NetworkInterfaces {
|
||||
if iface == nil {
|
||||
// Probably will never happen, but just in case
|
||||
return nil, errors.New("response integrity error: interface entry is nil")
|
||||
}
|
||||
|
||||
// Populate default IP addresses/DNS name similar to how we do
|
||||
// for the entire instance.
|
||||
if aws.StringValue(iface.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(iface.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(iface.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Iterate through the private IP addresses and log the
|
||||
// information.
|
||||
for _, addr := range iface.PrivateIpAddresses {
|
||||
if addr == nil {
|
||||
return nil, errors.New("response integrity error: interface address entry is nil")
|
||||
}
|
||||
|
||||
// Add private address/dns name if they have not been added yet
|
||||
if aws.StringValue(addr.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(addr.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Add public address/dns name if they have not been added yet
|
||||
if addr.Association != nil && aws.StringValue(addr.Association.PublicIp) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Association.PublicIp))
|
||||
}
|
||||
if addr.Association != nil && aws.StringValue(addr.Association.PublicDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the IPv6 addresses.
|
||||
for _, addr := range iface.Ipv6Addresses {
|
||||
if addr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if addr.Ipv6Address != nil && aws.StringValue(addr.Ipv6Address) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Ipv6Address))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringInSlice(s []string, x string) bool {
|
||||
for _, y := range s {
|
||||
if x == y {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
// AwsPlugin contains a collection of all aws plugin services.
|
||||
type AwsPlugin struct {
|
||||
// HostPlugin implements the HostPluginServiceServer interface for
|
||||
// supporting dynamically sourcing hosts from Amazon EC2
|
||||
*host.HostPlugin
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
## Getting Started
|
||||
|
||||
Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on
|
||||
configuration options for host catalogs and sets.
|
||||
|
||||
To create a host catalog (using default scope created by `boundary dev`):
|
||||
|
||||
```
|
||||
boundary host-catalogs create plugin \
|
||||
-scope-id p_1234567890 \
|
||||
-name "Example Plugin-Based Host Catalog" \
|
||||
-description "Description for plugin-based host catalog" \
|
||||
-plugin-name aws \
|
||||
-attr region=REGION \
|
||||
-secret access_key_id='KEY' \
|
||||
-secret secret_access_key='SECRET'
|
||||
```
|
||||
|
||||
To create a host set, filtering the host set based on tag keys `foo` or `bar`
|
||||
(either tag can be present), ensuring that any targets set to this host set only
|
||||
connect to external addresses in the `54.0.0.0/8` class A subnet:
|
||||
|
||||
```
|
||||
boundary host-sets create plugin \
|
||||
-host-catalog-id HOST_CATALOG_ID \
|
||||
-name "Example Plugin-Based Host Set" \
|
||||
-description "Description for plugin-based host set" \
|
||||
-attr filters=tag-key=foo,bar \
|
||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||
```
|
||||
|
||||
As above, but instances must have both tags (both `foo` and `bar` *must* be
|
||||
present):
|
||||
|
||||
```
|
||||
boundary host-sets create plugin \
|
||||
-host-catalog-id HOST_CATALOG_ID \
|
||||
-name "Example Plugin-Based Host Set" \
|
||||
-description "Description for plugin-based host set" \
|
||||
-attr filters=tag-key=foo \
|
||||
-attr filters=tag-key=bar \
|
||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||
```
|
||||
|
||||
As above, but matching on tag key named "development" with value "prod" or "dev"
|
||||
and launch date:
|
||||
|
||||
```
|
||||
boundary host-sets create plugin \
|
||||
-host-catalog-id HOST_CATALOG_ID \
|
||||
-name "Example Plugin-Based Host Set" \
|
||||
-description "Description for plugin-based host set" \
|
||||
-attr filters=tag:development=prod,dev \
|
||||
-attr filters=launch-time=2022-01-04T* \
|
||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||
```
|
||||
|
||||
## Required IAM Privileges
|
||||
|
||||
The following IAM privileges, at the very least, are required to be attached to
|
||||
a configured IAM user for this provider:
|
||||
|
||||
* `ec2:DescribeInstances`, configured to `*` (`DescribeInstances` cannot be
|
||||
scoped to a resource ARN). Example policy:
|
||||
|
||||
```
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"ec2:DescribeInstances"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to
|
||||
the IAM user to allow credential rotation. Example policy:
|
||||
|
||||
```
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"iam:DeleteAccessKey",
|
||||
"iam:GetUser",
|
||||
"iam:CreateAccessKey"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Resource": "arn:aws:iam::123456789012:user/JohnDoe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Attributes and Secrets
|
||||
|
||||
### Host Catalog
|
||||
|
||||
The following `attributes` are valid on an AWS host catalog resource:
|
||||
|
||||
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
|
||||
be performed. See the [Credential Rotation](#credential-rotation) section
|
||||
above.
|
||||
- `region` (string): The region to configure the host catalog for. All host sets
|
||||
in this catalog will be configured for this region.
|
||||
|
||||
The following `secrets` are valid on an AWS host catalog resource:
|
||||
|
||||
- `access_key_id` (string): The access key ID for the IAM user to use with this
|
||||
host catalog.
|
||||
- `secret_access_key` (string): The secret access key for the IAM user to use
|
||||
with this host catalog.
|
||||
|
||||
### Host Set
|
||||
|
||||
The following attributes are valid on an AWS host Set resource:
|
||||
|
||||
- `filters` (array of strings): An array of string filters in the format
|
||||
`key=val1,val2`. The key corresponds to a filter option, and the value(s) are
|
||||
a comma-separated list. For a list of filter options, check out
|
||||
[describe-instances in the AWS CLI
|
||||
reference](https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html).
|
||||
@@ -0,0 +1,90 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
cred "github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/errors"
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/values"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
// CatalogAttributes is a Go-native representation of the Attributes
|
||||
// map.
|
||||
type CatalogAttributes struct {
|
||||
*cred.CredentialAttributes
|
||||
}
|
||||
|
||||
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||
unknownFields := values.StructFields(in)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
var err error
|
||||
credAttributes, err := cred.GetCredentialAttributes(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for s := range unknownFields {
|
||||
switch s {
|
||||
// Ignore knownFields from CredentialAttributes
|
||||
case cred.ConstRegion:
|
||||
continue
|
||||
case cred.ConstDisableCredentialRotation:
|
||||
continue
|
||||
default:
|
||||
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
||||
}
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Invalid arguments in catalog attributes", badFields)
|
||||
}
|
||||
|
||||
return &CatalogAttributes{
|
||||
CredentialAttributes: credAttributes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetAttributes is a Go-native representation of the Attributes map that can be
|
||||
// used for decoding the incoming map via mapstructure.
|
||||
type SetAttributes struct {
|
||||
Filters []string
|
||||
}
|
||||
|
||||
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
var setAttrs SetAttributes
|
||||
|
||||
// Quick validation to ensure that there's no non-filter attributes
|
||||
// here for now. Make this more complex if we add more attributes
|
||||
// to host sets.
|
||||
unknownFields := values.StructFields(in)
|
||||
badFields := make(map[string]string)
|
||||
delete(unknownFields, ConstDescribeInstancesFilters)
|
||||
for a := range unknownFields {
|
||||
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
|
||||
}
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
|
||||
}
|
||||
|
||||
// Mapstructure complains if it expects a slice as output and sees a scalar
|
||||
// value. Rather than use WeakDecode and risk unintended consequences, I'm
|
||||
// manually making this change if necessary.
|
||||
inMap := in.AsMap()
|
||||
if filtersRaw, ok := inMap[ConstDescribeInstancesFilters]; ok {
|
||||
switch filterVal := filtersRaw.(type) {
|
||||
case string:
|
||||
inMap[ConstDescribeInstancesFilters] = []string{filterVal}
|
||||
}
|
||||
}
|
||||
|
||||
if err := mapstructure.Decode(inMap, &setAttrs); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
||||
}
|
||||
|
||||
return &setAttrs, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestGetCatalogAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
expected *CatalogAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing region",
|
||||
in: map[string]any{},
|
||||
expectedErrContains: "missing required value \"region\"",
|
||||
},
|
||||
{
|
||||
name: "bad value for disable_credential_rotation",
|
||||
in: map[string]any{
|
||||
credential.ConstRegion: "us-west-2",
|
||||
credential.ConstDisableCredentialRotation: "sure",
|
||||
},
|
||||
expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: map[string]any{
|
||||
credential.ConstRegion: "us-west-2",
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
in: map[string]any{
|
||||
credential.ConstRegion: "us-west-2",
|
||||
},
|
||||
expected: &CatalogAttributes{
|
||||
CredentialAttributes: &credential.CredentialAttributes{
|
||||
Region: "us-west-2",
|
||||
DisableCredentialRotation: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with disable_credential_rotation",
|
||||
in: map[string]any{
|
||||
credential.ConstRegion: "us-west-2",
|
||||
credential.ConstDisableCredentialRotation: true,
|
||||
},
|
||||
expected: &CatalogAttributes{
|
||||
CredentialAttributes: &credential.CredentialAttributes{
|
||||
Region: "us-west-2",
|
||||
DisableCredentialRotation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
input, err := structpb.NewStruct(tc.in)
|
||||
require.NoError(err)
|
||||
|
||||
actual, err := getCatalogAttributes(input)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
normalized map[string]any
|
||||
expected *SetAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
in: map[string]any{},
|
||||
expected: &SetAttributes{},
|
||||
},
|
||||
{
|
||||
name: "non-string-slice value",
|
||||
in: map[string]any{
|
||||
ConstDescribeInstancesFilters: "zip=foo,bar",
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"zip=foo,bar"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad filter element value",
|
||||
in: map[string]any{
|
||||
ConstDescribeInstancesFilters: []any{1},
|
||||
},
|
||||
expectedErrContains: "expected type 'string', got unconvertible type 'float64'",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: map[string]any{
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: map[string]any{
|
||||
ConstDescribeInstancesFilters: []any{
|
||||
"foo=bar",
|
||||
"zip=zap",
|
||||
},
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"foo=bar", "zip=zap"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good with filter transform",
|
||||
in: map[string]any{
|
||||
ConstDescribeInstancesFilters: "foo=bar",
|
||||
},
|
||||
normalized: map[string]any{
|
||||
ConstDescribeInstancesFilters: []any{"foo=bar"},
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"foo=bar"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
input, err := structpb.NewStruct(tc.in)
|
||||
require.NoError(err)
|
||||
|
||||
p := new(HostPlugin)
|
||||
normalizedOut, err := p.NormalizeSetData(context.Background(), &plugin.NormalizeSetDataRequest{Attributes: input})
|
||||
require.NoError(err)
|
||||
|
||||
if tc.normalized != nil {
|
||||
normalized, err := structpb.NewStruct(tc.normalized)
|
||||
require.NoError(err)
|
||||
require.Empty(cmp.Diff(normalized, normalizedOut.Attributes, protocmp.Transform()))
|
||||
}
|
||||
input = normalizedOut.Attributes
|
||||
actual, err := getSetAttributes(input)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package host
|
||||
|
||||
const (
|
||||
// ConstDescribeInstancesFilters is the key for the filter in the dynamic host set.
|
||||
ConstDescribeInstancesFilters = "filters"
|
||||
|
||||
// ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2
|
||||
// instance's name. This field is to be matched literally.
|
||||
ConstInstanceNameTagKey = "Name"
|
||||
)
|
||||
@@ -0,0 +1,725 @@
|
||||
package host
|
||||
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
cred "github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
)
|
||||
|
||||
// HostPlugin implements the HostPluginServiceServer interface for the
|
||||
// AWS host service plugin.
|
||||
type HostPlugin struct {
|
||||
pb.UnimplementedHostPluginServiceServer
|
||||
|
||||
// testCredStateOpts are passed in to the stored state to control test behavior
|
||||
testCredStateOpts []credential.AwsCredentialPersistedStateOption
|
||||
|
||||
// testCatalogStateOpts are passed in to the stored state to control test behavior
|
||||
testCatalogStateOpts []awsCatalogPersistedStateOption
|
||||
}
|
||||
|
||||
// Ensure that we are implementing HostPluginServiceServer
|
||||
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
||||
|
||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
||||
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
secrets := catalog.GetSecrets()
|
||||
if secrets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "secrets are required")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credConfig, err := cred.GetCredentialsConfig(secrets, catalogAttributes.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set up the creds in our persisted state.
|
||||
credState, err := credential.NewAwsCredentialPersistedState(
|
||||
append([]credential.AwsCredentialPersistedStateOption{
|
||||
credential.WithAccessKeyId(credConfig.AccessKey),
|
||||
credential.WithSecretAccessKey(credConfig.SecretKey),
|
||||
credential.WithRegion(credConfig.Region),
|
||||
}, p.testCredStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
|
||||
}
|
||||
|
||||
// Try to rotate the credentials if we're not skipping them.
|
||||
if !catalogAttributes.DisableCredentialRotation {
|
||||
if err := credState.RotateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||
}
|
||||
} else {
|
||||
// Simply validate if we aren't rotating.
|
||||
if err := credState.ValidateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
persistedProto, err := catalogState.toProto()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &pb.OnCreateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnUpdateCatalog is called when a dynamic host catalog is updated.
|
||||
func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
||||
// For host catalogs we only need to really validate what is
|
||||
// currently in the new copy of the host catalog data, so skip
|
||||
// fetching the new copy to keep things a little less messy.
|
||||
catalog := req.GetNewCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
var updateSecrets bool
|
||||
secrets := catalog.GetSecrets()
|
||||
if secrets != nil {
|
||||
// We will be updating secrets this run, but what exactly that
|
||||
// means will be determined later.
|
||||
updateSecrets = true
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the persisted data.
|
||||
// NOTE: We might need to change this at a later time. I'm not too
|
||||
// sure *exactly* what scenarios we might encounter that would
|
||||
// ultimately mean that we would have to handle an empty or missing
|
||||
// state in update, but what we are ultimately assuming here
|
||||
// (implicitly through awsCatalogPersistedStateFromProto) is that
|
||||
// the state will exist and be populated. Personally I think this
|
||||
// is fine and important, but this may change in the future.
|
||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
if catalogAttributes.DisableCredentialRotation && !updateSecrets {
|
||||
// This is a validate check to make sure that we aren't disabling
|
||||
// rotation for credentials currently being managed by rotation.
|
||||
// This is not allowed.
|
||||
if !credState.CredsLastRotatedTime.IsZero() {
|
||||
return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials")
|
||||
}
|
||||
}
|
||||
|
||||
if updateSecrets {
|
||||
catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Replace the credentials. This checks the timestamp on the last
|
||||
// rotation time as well and deletes the credentials if we are
|
||||
// managing them (ie: if we've rotated them before).
|
||||
if err := credState.ReplaceCreds(catalogSecrets.AccessKey, catalogSecrets.SecretKey); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
|
||||
// If we're enabling rotation now but didn't before, or have
|
||||
// freshly replaced credentials, we can rotate here.
|
||||
if err := credState.RotateCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// That's it!
|
||||
persistedProto, err := catalogState.toProto()
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
return &pb.OnUpdateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
|
||||
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the persisted data.
|
||||
// NOTE: We return on error here, blocking the delete. This may or
|
||||
// may not be an overzealous approach to maintaining database/state
|
||||
// integrity. May need to be changed at later time if there are
|
||||
// scenarios where we might be deleting things and any secret state
|
||||
// may be corrupt/and or legitimately missing.
|
||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
_, err = newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
if !credState.CredsLastRotatedTime.IsZero() {
|
||||
// Delete old/existing credentials. This is done with the same
|
||||
// credentials to ensure that it has the proper permissions to do
|
||||
// it.
|
||||
if err := credState.DeleteCreds(); err != nil {
|
||||
return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.OnDeleteCatalogResponse{}, nil
|
||||
}
|
||||
|
||||
// NormalizeSetData currently ensures that "filters" is an array value, even
|
||||
// though it's accepted as a string value for CLI UX reasons
|
||||
func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
|
||||
if req.Attributes == nil {
|
||||
return new(pb.NormalizeSetDataResponse), nil
|
||||
}
|
||||
|
||||
val := req.Attributes.Fields["filters"]
|
||||
if val == nil {
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
stringVal, ok := val.Kind.(*structpb.Value_StringValue)
|
||||
if !ok {
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
|
||||
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
|
||||
retAttrs.Fields["filters"] = structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue(stringVal.StringValue),
|
||||
},
|
||||
})
|
||||
|
||||
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
|
||||
}
|
||||
|
||||
// OnCreateSet is called when a dynamic host set is created.
|
||||
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
set := req.GetSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set is nil")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ec2Client, err := catalogState.EC2Client(catalogAttributes.Region)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||
}
|
||||
|
||||
_, err = ec2Client.DescribeInstances(input)
|
||||
if err == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||
}
|
||||
|
||||
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||
// Success
|
||||
return &pb.OnCreateSetResponse{}, nil
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||
}
|
||||
|
||||
// OnUpdateSet is called when a dynamic host set is updated.
|
||||
func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
// As with catalog, we don't need to really look at the old host
|
||||
// set here, just need to work off of/validate the new set config
|
||||
set := req.GetNewSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new set is nil")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ec2Client, err := catalogState.EC2Client(catalogAttributes.Region)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||
}
|
||||
|
||||
_, err = ec2Client.DescribeInstances(input)
|
||||
if err == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||
}
|
||||
|
||||
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||
// Success
|
||||
return &pb.OnUpdateSetResponse{}, nil
|
||||
}
|
||||
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||
}
|
||||
|
||||
// OnDeleteSet is called when a dynamic host set is deleted.
|
||||
func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
||||
// No-op, AWS host set does not maintain anything that requires
|
||||
// cleanup
|
||||
return &pb.OnDeleteSetResponse{}, nil
|
||||
}
|
||||
|
||||
// ListHosts returns the list of ec2 hosts and their descriptions.
|
||||
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.AwsCredentialPersistedStateFromProto(req.GetPersisted().GetSecrets(), p.testCredStateOpts...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newAwsCatalogPersistedState(
|
||||
append([]awsCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
sets := req.GetSets()
|
||||
if sets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "sets is nil")
|
||||
}
|
||||
|
||||
// Build all the queries in advance.
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
Input *ec2.DescribeInstancesInput
|
||||
Output *ec2.DescribeInstancesOutput
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
queries := make([]hostSetQuery, len(sets))
|
||||
for i, set := range sets {
|
||||
// Validate Id since we use it in output
|
||||
if set.GetId() == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing id")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := buildDescribeInstancesInput(setAttrs, false)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
|
||||
}
|
||||
queries[i] = hostSetQuery{
|
||||
Id: set.GetId(),
|
||||
Input: input,
|
||||
}
|
||||
}
|
||||
|
||||
ec2Client, err := catalogState.EC2Client(catalogAttributes.Region)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
// Run all queries now and assemble output.
|
||||
var maxLen int
|
||||
for i, query := range queries {
|
||||
output, err := ec2Client.DescribeInstances(query.Input)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
|
||||
// Process the output here, we will normalize this into a single
|
||||
// set of hosts afterwards (possibly removing duplicates).
|
||||
for _, reservation := range output.Reservations {
|
||||
for _, instance := range reservation.Instances {
|
||||
host, err := awsInstanceToHost(instance)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||
maxLen++ // Increment maximum counter for allocation later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now de-duplicate the hosts for the output. Maintain two sets:
|
||||
// * A slice of hosts that will be used in the output
|
||||
// * A map of hosts indexed by their external ID
|
||||
//
|
||||
// The map will is used in de-duplication to determine whether or
|
||||
// not we've seen the host before to simply add the query's set ID
|
||||
// to the list of set IDs that the host was seen in.
|
||||
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
||||
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
||||
for _, query := range queries {
|
||||
for _, host := range query.OutputHosts {
|
||||
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
||||
// Existing host, just add the set ID to the list of seen IDs
|
||||
// and continue
|
||||
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// This will be the first seen entry, so append the set ID to
|
||||
// this host, and add it.
|
||||
host.SetIds = append(host.SetIds, query.Id)
|
||||
hostResultSlice = append(hostResultSlice, host)
|
||||
hostResultMap[host.ExternalId] = host
|
||||
}
|
||||
}
|
||||
|
||||
// Done!
|
||||
return &pb.ListHostsResponse{
|
||||
Hosts: hostResultSlice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildFilters(attrs *SetAttributes) ([]*ec2.Filter, error) {
|
||||
var filters []*ec2.Filter
|
||||
var foundStateFilter bool
|
||||
for _, filterAttr := range attrs.Filters {
|
||||
// Each filter arrives in "k=v1,v2" format. First, split on equal sign.
|
||||
splitFilter := strings.Split(filterAttr, "=")
|
||||
switch {
|
||||
case len(splitFilter) != 2:
|
||||
return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr)
|
||||
case len(splitFilter[0]) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
|
||||
case len(splitFilter[1]) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
|
||||
}
|
||||
|
||||
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
||||
if filterKey == "instance-state-code" || filterKey == "instance-state-name" {
|
||||
foundStateFilter = true
|
||||
}
|
||||
|
||||
var filterValues []*string
|
||||
for _, val := range strings.Split(filterValue, ",") {
|
||||
filterValues = append(filterValues, aws.String(val))
|
||||
}
|
||||
|
||||
filters = append(filters, &ec2.Filter{
|
||||
Name: aws.String(filterKey),
|
||||
Values: filterValues,
|
||||
})
|
||||
}
|
||||
|
||||
if !foundStateFilter {
|
||||
// if there is no explicit instance state filter, add the
|
||||
// instance-state-name = ["running"] to the filter set. This
|
||||
// ensures that we filter on running instances only at the API
|
||||
// side, saving time when processing results.
|
||||
filters = append(filters, &ec2.Filter{
|
||||
Name: aws.String("instance-state-name"),
|
||||
Values: aws.StringSlice([]string{ec2.InstanceStateNameRunning}),
|
||||
})
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
||||
filters, err := buildFilters(attrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building filters: %w", err)
|
||||
}
|
||||
|
||||
return &ec2.DescribeInstancesInput{
|
||||
DryRun: aws.Bool(dryRun),
|
||||
Filters: filters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// awsInstanceToHost processes data from an ec2.Instance and returns
|
||||
// a ListHostsResponseHost with the ID and network addresses
|
||||
// populated.
|
||||
func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) {
|
||||
// Integrity check: some fields should always be non-nil. Check
|
||||
// those.
|
||||
if instance == nil {
|
||||
return nil, errors.New("response integrity error: missing instance entry")
|
||||
}
|
||||
|
||||
if aws.StringValue(instance.InstanceId) == "" {
|
||||
return nil, errors.New("response integrity error: missing instance id")
|
||||
}
|
||||
|
||||
result := new(pb.ListHostsResponseHost)
|
||||
|
||||
// External ID is the instance ID.
|
||||
result.ExternalId = aws.StringValue(instance.InstanceId)
|
||||
|
||||
// External Name is the AWS "Name" tag, if it exists.
|
||||
for _, t := range instance.Tags {
|
||||
if aws.StringValue(t.Key) == ConstInstanceNameTagKey {
|
||||
result.ExternalName = aws.StringValue(t.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// First IP address/dns name are always the private fields if they
|
||||
// are populated
|
||||
if aws.StringValue(instance.PrivateIpAddress) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(instance.PrivateDnsName) != "" {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Public IP address/dns names are next
|
||||
if aws.StringValue(instance.PublicIpAddress) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress))
|
||||
}
|
||||
if aws.StringValue(instance.PublicDnsName) != "" {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PublicDnsName))
|
||||
}
|
||||
|
||||
// Now go through all of the interfaces and log the IP address of
|
||||
// every interface.
|
||||
for _, iface := range instance.NetworkInterfaces {
|
||||
if iface == nil {
|
||||
// Probably will never happen, but just in case
|
||||
return nil, errors.New("response integrity error: interface entry is nil")
|
||||
}
|
||||
|
||||
// Populate default IP addresses/DNS name similar to how we do
|
||||
// for the entire instance.
|
||||
if aws.StringValue(iface.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(iface.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(iface.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Iterate through the private IP addresses and log the
|
||||
// information.
|
||||
for _, addr := range iface.PrivateIpAddresses {
|
||||
if addr == nil {
|
||||
return nil, errors.New("response integrity error: interface address entry is nil")
|
||||
}
|
||||
|
||||
// Add private address/dns name if they have not been added yet
|
||||
if aws.StringValue(addr.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress))
|
||||
}
|
||||
if aws.StringValue(addr.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.PrivateDnsName))
|
||||
}
|
||||
|
||||
// Add public address/dns name if they have not been added yet
|
||||
if addr.Association != nil && aws.StringValue(addr.Association.PublicIp) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Association.PublicIp))
|
||||
}
|
||||
if addr.Association != nil && aws.StringValue(addr.Association.PublicDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) {
|
||||
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the IPv6 addresses.
|
||||
for _, addr := range iface.Ipv6Addresses {
|
||||
if addr == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if addr.Ipv6Address != nil && aws.StringValue(addr.Ipv6Address) != "" {
|
||||
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Ipv6Address))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringInSlice(s []string, x string) bool {
|
||||
for _, y := range s {
|
||||
if x == y {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
var customClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
type ec2APIFunc func(*session.Session, ...*aws.Config) (ec2iface.EC2API, error)
|
||||
|
||||
type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error
|
||||
|
||||
type awsCatalogPersistedState struct {
|
||||
*credential.AwsCredentialPersistedState
|
||||
|
||||
// testEC2APIFunc provides a way to provide a factory for a mock EC2 client
|
||||
testEC2APIFunc ec2APIFunc
|
||||
}
|
||||
|
||||
func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.testEC2APIFunc != nil {
|
||||
return errors.New("test API function already set")
|
||||
}
|
||||
|
||||
s.testEC2APIFunc = f
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withCredentials(x *credential.AwsCredentialPersistedState) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.AwsCredentialPersistedState != nil {
|
||||
return errors.New("aws credentials already set")
|
||||
}
|
||||
|
||||
s.AwsCredentialPersistedState = x
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
||||
s := new(awsCatalogPersistedState)
|
||||
for _, opt := range opts {
|
||||
if err := opt(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
||||
data, err := structpb.NewStruct(s.ToMap())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||
}
|
||||
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||
}
|
||||
|
||||
// EC2Client returns a configured EC2 client based on the session
|
||||
// information stored in the state.
|
||||
func (s *awsCatalogPersistedState) EC2Client(region string) (ec2iface.EC2API, error) {
|
||||
sess, err := s.GetSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err)
|
||||
}
|
||||
|
||||
if s.testEC2APIFunc != nil {
|
||||
return s.testEC2APIFunc(sess, aws.NewConfig().WithRegion(region))
|
||||
}
|
||||
|
||||
cfg := aws.NewConfig()
|
||||
if region != "" {
|
||||
cfg = cfg.WithRegion(region)
|
||||
}
|
||||
|
||||
cfg.HTTPClient = customClient
|
||||
|
||||
return ec2.New(sess, cfg), nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-host-aws/internal/credential"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAwsCatalogPersistedStateEC2Client(t *testing.T) {
|
||||
require := require.New(t)
|
||||
cred, err := credential.NewAwsCredentialPersistedState(
|
||||
credential.WithRegion("us-west-2"),
|
||||
credential.WithAccessKeyId("foobar"),
|
||||
credential.WithSecretAccessKey("barfoo"),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
state, err := newAwsCatalogPersistedState(
|
||||
withCredentials(cred),
|
||||
withTestEC2APIFunc(newTestMockEC2(nil)),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
clientRaw, err := state.EC2Client("us-west-2")
|
||||
require.NoError(err)
|
||||
require.NotNil(clientRaw)
|
||||
client, ok := clientRaw.(*testMockEC2)
|
||||
require.True(ok)
|
||||
require.NotNil(client)
|
||||
require.Equal("us-west-2", client.Region)
|
||||
}
|
||||
@@ -1,52 +1,19 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
package host
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/iam/iamiface"
|
||||
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const testDescribeInstancesError = "DescribeInstances error"
|
||||
|
||||
type testMockIAMState struct {
|
||||
DeleteAccessKeyCalled bool
|
||||
}
|
||||
|
||||
func (s *testMockIAMState) Reset() {
|
||||
s.DeleteAccessKeyCalled = false
|
||||
}
|
||||
|
||||
type testMockIAM struct {
|
||||
iamiface.IAMAPI
|
||||
|
||||
State *testMockIAMState
|
||||
}
|
||||
|
||||
func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc {
|
||||
return func(sess *session.Session) (iamiface.IAMAPI, error) {
|
||||
m := &testMockIAM{
|
||||
State: state,
|
||||
}
|
||||
f := awsutil.NewMockIAM(opts...)
|
||||
var err error
|
||||
|
||||
m.IAMAPI, err = f(sess)
|
||||
return m, err
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMockIAM) DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
|
||||
m.State.DeleteAccessKeyCalled = true
|
||||
return m.IAMAPI.DeleteAccessKey(input)
|
||||
}
|
||||
const (
|
||||
testOptionErr = "test option error"
|
||||
testGetUserErr = "test error for GetUser"
|
||||
testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
||||
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
||||
testDescribeInstancesError = "DescribeInstances error"
|
||||
)
|
||||
|
||||
type testMockEC2State struct {
|
||||
DescribeInstancesCalled bool
|
||||
@@ -117,15 +84,6 @@ func (m *testMockEC2) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2
|
||||
return m.DescribeInstancesOutput, nil
|
||||
}
|
||||
|
||||
func mustStruct(in map[string]interface{}) *structpb.Struct {
|
||||
out, err := structpb.NewStruct(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type ec2FilterSorter []*ec2.Filter
|
||||
|
||||
func (s ec2FilterSorter) Len() int { return len(s) }
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
type ec2APIFunc func(*session.Session, ...*aws.Config) (ec2iface.EC2API, error)
|
||||
|
||||
type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error
|
||||
|
||||
func withStateTestOpts(opts []awsutil.Option) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
s.testOpts = opts
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.testEC2APIFunc != nil {
|
||||
return errors.New("test API function already set")
|
||||
}
|
||||
|
||||
s.testEC2APIFunc = f
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withAccessKeyId(x string) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.AccessKeyId != "" {
|
||||
return errors.New("access key id already set")
|
||||
}
|
||||
|
||||
s.AccessKeyId = x
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withSecretAccessKey(x string) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.SecretAccessKey != "" {
|
||||
return errors.New("secret access key already set")
|
||||
}
|
||||
|
||||
s.SecretAccessKey = x
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withCredsLastRotatedTime(t time.Time) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if !s.CredsLastRotatedTime.IsZero() {
|
||||
return errors.New("last rotation time already set")
|
||||
}
|
||||
|
||||
s.CredsLastRotatedTime = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withRegion(with string) awsCatalogPersistedStateOption {
|
||||
return func(s *awsCatalogPersistedState) error {
|
||||
if s.region != "" {
|
||||
return errors.New("region already set")
|
||||
}
|
||||
|
||||
s.region = with
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type awsCatalogPersistedState struct {
|
||||
AccessKeyId string
|
||||
SecretAccessKey string
|
||||
CredsLastRotatedTime time.Time
|
||||
|
||||
// testOpts are options that should be used for testing only
|
||||
testOpts []awsutil.Option
|
||||
|
||||
// testEC2APIFunc provides a way to provide a factory for a mock EC2 client
|
||||
testEC2APIFunc ec2APIFunc
|
||||
|
||||
// Region is not a part of persisted state but if set is passed to clients
|
||||
// created with this state
|
||||
region string
|
||||
}
|
||||
|
||||
func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
||||
s := new(awsCatalogPersistedState)
|
||||
for _, opt := range opts {
|
||||
if err := opt(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted, opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
||||
data := in.GetSecrets()
|
||||
if data == nil {
|
||||
return nil, errors.New("missing persisted secrets")
|
||||
}
|
||||
|
||||
accessKeyId, err := getStringValue(data, constAccessKeyId, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
secretAccessKey, err := getStringValue(data, constSecretAccessKey, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
credsLastRotatedTime, err := getTimeValue(data, constCredsLastRotatedTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
s, err := newAwsCatalogPersistedState(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.AccessKeyId = accessKeyId
|
||||
s.SecretAccessKey = secretAccessKey
|
||||
s.CredsLastRotatedTime = credsLastRotatedTime
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) {
|
||||
data, err := structpb.NewStruct(map[string]interface{}{
|
||||
constAccessKeyId: s.AccessKeyId,
|
||||
constSecretAccessKey: s.SecretAccessKey,
|
||||
constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||
}
|
||||
|
||||
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||
}
|
||||
|
||||
func (s *awsCatalogPersistedState) ValidateCreds() error {
|
||||
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||
awsutil.WithAccessKey(s.AccessKeyId),
|
||||
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||
awsutil.WithRegion(s.region),
|
||||
}, s.testOpts...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading credentials: %w", err)
|
||||
}
|
||||
|
||||
if _, err := c.GetCallerIdentity(s.testOpts...); err != nil {
|
||||
return fmt.Errorf("error validating credentials: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *awsCatalogPersistedState) RotateCreds() error {
|
||||
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||
awsutil.WithAccessKey(s.AccessKeyId),
|
||||
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||
awsutil.WithRegion(s.region),
|
||||
}, s.testOpts...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := c.RotateKeys(append([]awsutil.Option{
|
||||
awsutil.WithValidityCheckTimeout(rotationWaitTimeout),
|
||||
}, s.testOpts...)...); err != nil {
|
||||
return fmt.Errorf("error rotating credentials: %w", err)
|
||||
}
|
||||
|
||||
s.AccessKeyId = c.AccessKey
|
||||
s.SecretAccessKey = c.SecretKey
|
||||
s.CredsLastRotatedTime = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceCreds replaces the access key in the state with a new key.
|
||||
// If the existing key was rotated at any point in time, it is
|
||||
// deleted first, otherwise it's left alone.
|
||||
func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error {
|
||||
if accessKeyId == "" {
|
||||
return errors.New("access key id cannot be empty")
|
||||
}
|
||||
|
||||
if secretAccessKey == "" {
|
||||
return errors.New("secret access key cannot be empty")
|
||||
}
|
||||
|
||||
if accessKeyId == s.AccessKeyId {
|
||||
return errors.New("attempting to replace access key with the same one")
|
||||
}
|
||||
|
||||
if !s.CredsLastRotatedTime.IsZero() {
|
||||
// Delete old/existing credentials. This is done with the same
|
||||
// credentials to ensure that it has the proper permissions to do
|
||||
// it.
|
||||
if err := s.DeleteCreds(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set the new attributes and clear the rotated time.
|
||||
s.AccessKeyId = accessKeyId
|
||||
s.SecretAccessKey = secretAccessKey
|
||||
s.CredsLastRotatedTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCreds deletes the credentials in the state. The access key
|
||||
// ID, secret access key, and rotation time fields are zeroed out in
|
||||
// the state just to ensure that they cannot be re-used after.
|
||||
func (s *awsCatalogPersistedState) DeleteCreds() error {
|
||||
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||
awsutil.WithAccessKey(s.AccessKeyId),
|
||||
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||
awsutil.WithRegion(s.region),
|
||||
}, s.testOpts...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading credentials: %w", err)
|
||||
}
|
||||
|
||||
if err := c.DeleteAccessKey(s.AccessKeyId, s.testOpts...); err != nil {
|
||||
// Determine if the deletion error was due to a missing
|
||||
// resource. If it was, just pass it.
|
||||
var awsErr awserr.Error
|
||||
if errors.As(err, &awsErr) {
|
||||
if awsErr.Code() == iam.ErrCodeNoSuchEntityException {
|
||||
s.AccessKeyId = ""
|
||||
s.SecretAccessKey = ""
|
||||
s.CredsLastRotatedTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise treat it as an actual error.
|
||||
return err
|
||||
}
|
||||
|
||||
s.AccessKeyId = ""
|
||||
s.SecretAccessKey = ""
|
||||
s.CredsLastRotatedTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns a configured AWS session for the credentials in
|
||||
// the state.
|
||||
func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) {
|
||||
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||
awsutil.WithAccessKey(s.AccessKeyId),
|
||||
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||
awsutil.WithRegion(s.region),
|
||||
}, s.testOpts...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.GetSession()
|
||||
}
|
||||
|
||||
var customClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
DualStack: true,
|
||||
}).DialContext,
|
||||
},
|
||||
}
|
||||
|
||||
// EC2Client returns a configured EC2 client based on the session
|
||||
// information stored in the state.
|
||||
func (s *awsCatalogPersistedState) EC2Client(opt ...Option) (ec2iface.EC2API, error) {
|
||||
opts, err := getOpts(opt...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing options when fetching EC2 client: %w", err)
|
||||
}
|
||||
|
||||
region := opts.withRegion
|
||||
if region == "" {
|
||||
region = s.region
|
||||
}
|
||||
|
||||
sess, err := s.GetSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting AWS session when fetching EC2 client: %w", err)
|
||||
}
|
||||
|
||||
if s.testEC2APIFunc != nil {
|
||||
return s.testEC2APIFunc(sess, aws.NewConfig().WithRegion(region))
|
||||
}
|
||||
|
||||
cfg := aws.NewConfig()
|
||||
if region != "" {
|
||||
cfg = cfg.WithRegion(region)
|
||||
}
|
||||
|
||||
cfg.HTTPClient = customClient
|
||||
|
||||
return ec2.New(sess, cfg), nil
|
||||
}
|
||||
@@ -1,686 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testOptionErr = "test option error"
|
||||
testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
||||
testGetUserErr = "test error for GetUser"
|
||||
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
||||
)
|
||||
|
||||
func TestNewAwsCatalogPersistedState(t *testing.T) {
|
||||
staticTime := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts []awsCatalogPersistedStateOption
|
||||
expected *awsCatalogPersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "error",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
func(s *awsCatalogPersistedState) error {
|
||||
return errors.New(testOptionErr)
|
||||
},
|
||||
},
|
||||
expectedErr: testOptionErr,
|
||||
},
|
||||
{
|
||||
name: "access key id",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withAccessKeyId("foobar"),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret access key",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withSecretAccessKey("bazqux"),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
SecretAccessKey: "bazqux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rotation time",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withCredsLastRotatedTime(staticTime),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
CredsLastRotatedTime: staticTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "region key",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withRegion("foobar"),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
region: "foobar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "double set access key id",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withAccessKeyId("foobar"),
|
||||
withAccessKeyId("onetwo"),
|
||||
},
|
||||
expectedErr: "access key id already set",
|
||||
},
|
||||
{
|
||||
name: "double set secret access key",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withSecretAccessKey("bazqux"),
|
||||
withSecretAccessKey("threefour"),
|
||||
},
|
||||
expectedErr: "secret access key already set",
|
||||
},
|
||||
{
|
||||
name: "double set rotation time",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withCredsLastRotatedTime(staticTime),
|
||||
withCredsLastRotatedTime(time.Now()),
|
||||
},
|
||||
expectedErr: "last rotation time already set",
|
||||
},
|
||||
{
|
||||
name: "double set region",
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withRegion("foobar"),
|
||||
withRegion("barfoo"),
|
||||
},
|
||||
expectedErr: "region already set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := newAwsCatalogPersistedState(tc.opts...)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateFromProto(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *pb.HostCatalogPersisted
|
||||
opts []awsCatalogPersistedStateOption
|
||||
expected *awsCatalogPersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "no secrets",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
expectedErr: "missing persisted secrets",
|
||||
},
|
||||
{
|
||||
name: "missing access key ID",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(nil),
|
||||
},
|
||||
expectedErr: "persisted state integrity error: missing required value \"access_key_id\"",
|
||||
},
|
||||
{
|
||||
name: "missing secret access key",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
}),
|
||||
},
|
||||
expectedErr: "persisted state integrity error: missing required value \"secret_access_key\"",
|
||||
},
|
||||
{
|
||||
name: "bad last rotated time",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: "notatime",
|
||||
}),
|
||||
},
|
||||
expectedErr: "persisted state integrity error: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"",
|
||||
},
|
||||
{
|
||||
name: "option error",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
}),
|
||||
},
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
func(s *awsCatalogPersistedState) error {
|
||||
return errors.New(testOptionErr)
|
||||
},
|
||||
},
|
||||
expectedErr: testOptionErr,
|
||||
},
|
||||
{
|
||||
name: "good with non-zero timestamp",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
}),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: func() time.Time {
|
||||
t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good with zero timestamp",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||
}),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: time.Time{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good (ignoring non-test options)",
|
||||
in: &pb.HostCatalogPersisted{
|
||||
// Nil should be the same as empty map here
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||
}),
|
||||
},
|
||||
opts: []awsCatalogPersistedStateOption{
|
||||
withAccessKeyId("ignored"),
|
||||
withRegion("someregion"),
|
||||
},
|
||||
expected: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: time.Time{},
|
||||
region: "someregion",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := awsCatalogPersistedStateFromProto(tc.in, tc.opts...)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateToProto(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *awsCatalogPersistedState
|
||||
expected *pb.HostCatalogPersisted
|
||||
expectedErr string // NOTE: Here for scaffolding but hard to test deep structpb err right now
|
||||
}{
|
||||
{
|
||||
name: "good with non-zero timestamp",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: func() time.Time {
|
||||
t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}(),
|
||||
},
|
||||
expected: &pb.HostCatalogPersisted{
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good with zero timestamp",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: time.Time{},
|
||||
},
|
||||
expected: &pb.HostCatalogPersisted{
|
||||
Secrets: mustStruct(map[string]interface{}{
|
||||
constAccessKeyId: "foobar",
|
||||
constSecretAccessKey: "bazqux",
|
||||
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := tc.in.ToProto()
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *awsCatalogPersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "could not load credentials",
|
||||
in: &awsCatalogPersistedState{
|
||||
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
in: &awsCatalogPersistedState{
|
||||
testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr))))},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr),
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
err := tc.in.ValidateCreds()
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *awsCatalogPersistedState
|
||||
expectedAccessKeyId string
|
||||
expectedSecretAccessKey string
|
||||
expectedNonZeroRotatedTime bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "could not load credentials",
|
||||
in: &awsCatalogPersistedState{
|
||||
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||
},
|
||||
{
|
||||
name: "rotation error",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(
|
||||
awsutil.WithGetUserError(errors.New(testGetUserErr)),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr),
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithSTSAPIFunc(
|
||||
awsutil.NewMockSTS(),
|
||||
),
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(
|
||||
awsutil.WithGetUserOutput(
|
||||
&iam.GetUserOutput{
|
||||
User: &iam.User{
|
||||
Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"),
|
||||
UserId: aws.String("AIDAJQABLZS4A3QDU576Q"),
|
||||
UserName: aws.String("JohnDoe"),
|
||||
},
|
||||
},
|
||||
),
|
||||
awsutil.WithCreateAccessKeyOutput(
|
||||
&iam.CreateAccessKeyOutput{
|
||||
AccessKey: &iam.AccessKey{
|
||||
AccessKeyId: aws.String("one"),
|
||||
SecretAccessKey: aws.String("two"),
|
||||
UserName: aws.String("JohnDoe"),
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
expectedAccessKeyId: "one",
|
||||
expectedSecretAccessKey: "two",
|
||||
expectedNonZeroRotatedTime: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
err := tc.in.RotateCreds()
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expectedAccessKeyId, tc.in.AccessKeyId)
|
||||
require.Equal(tc.expectedSecretAccessKey, tc.in.SecretAccessKey)
|
||||
require.Equal(tc.expectedNonZeroRotatedTime, !tc.in.CredsLastRotatedTime.IsZero())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) {
|
||||
// NOTE: Not safe to run this test in parallel
|
||||
state := new(testMockIAMState)
|
||||
cases := []struct {
|
||||
name string
|
||||
in *awsCatalogPersistedState
|
||||
accessKeyId string
|
||||
secretAccessKey string
|
||||
expectedAccessKeyId string
|
||||
expectedSecretAccessKey string
|
||||
expectedDeleteCalled bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "missing access key id",
|
||||
in: &awsCatalogPersistedState{},
|
||||
expectedErr: "access key id cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "missing access key id",
|
||||
in: &awsCatalogPersistedState{},
|
||||
accessKeyId: "one",
|
||||
expectedErr: "secret access key cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "identical access key id",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
},
|
||||
accessKeyId: "foobar",
|
||||
secretAccessKey: "bazqux",
|
||||
expectedErr: "attempting to replace access key with the same one",
|
||||
},
|
||||
{
|
||||
name: "deletion error",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: time.Now(),
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(
|
||||
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
accessKeyId: "one",
|
||||
secretAccessKey: "two",
|
||||
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
|
||||
},
|
||||
{
|
||||
name: "good with delete of old rotated",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
CredsLastRotatedTime: time.Now(),
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
newTestMockIAM(state),
|
||||
),
|
||||
},
|
||||
},
|
||||
accessKeyId: "one",
|
||||
secretAccessKey: "two",
|
||||
expectedAccessKeyId: "one",
|
||||
expectedSecretAccessKey: "two",
|
||||
expectedDeleteCalled: true,
|
||||
},
|
||||
{
|
||||
name: "good without delete of old rotated",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
newTestMockIAM(state),
|
||||
),
|
||||
},
|
||||
},
|
||||
accessKeyId: "one",
|
||||
secretAccessKey: "two",
|
||||
expectedAccessKeyId: "one",
|
||||
expectedSecretAccessKey: "two",
|
||||
expectedDeleteCalled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
state.Reset()
|
||||
err := tc.in.ReplaceCreds(tc.accessKeyId, tc.secretAccessKey)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expectedAccessKeyId, tc.in.AccessKeyId)
|
||||
require.Equal(tc.expectedSecretAccessKey, tc.in.SecretAccessKey)
|
||||
require.Zero(tc.in.CredsLastRotatedTime)
|
||||
require.Equal(tc.expectedDeleteCalled, state.DeleteAccessKeyCalled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *awsCatalogPersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "could not load credentials",
|
||||
in: &awsCatalogPersistedState{
|
||||
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||
},
|
||||
{
|
||||
name: "deletion error",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(
|
||||
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
|
||||
},
|
||||
{
|
||||
name: "deletion error, but OK because key was just gone",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(
|
||||
awsutil.WithDeleteAccessKeyError(
|
||||
awserr.New(iam.ErrCodeNoSuchEntityException, "", nil),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
in: &awsCatalogPersistedState{
|
||||
AccessKeyId: "foobar",
|
||||
SecretAccessKey: "bazqux",
|
||||
testOpts: []awsutil.Option{
|
||||
awsutil.WithIAMAPIFunc(
|
||||
awsutil.NewMockIAM(),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
err := tc.in.DeleteCreds()
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Empty(tc.in.AccessKeyId)
|
||||
require.Empty(tc.in.SecretAccessKey)
|
||||
require.Zero(tc.in.CredsLastRotatedTime)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateGetSessionErr(t *testing.T) {
|
||||
require := require.New(t)
|
||||
state := &awsCatalogPersistedState{
|
||||
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||
}
|
||||
|
||||
_, err := state.GetSession()
|
||||
require.EqualError(err, fmt.Sprintf("error reading options in NewCredentialsConfig: %s", testOptionErr))
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateEC2ClientErr(t *testing.T) {
|
||||
require := require.New(t)
|
||||
state, err := newAwsCatalogPersistedState(
|
||||
withStateTestOpts([]awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
_, err = state.EC2Client(WithRegion("someregion"))
|
||||
require.EqualError(err, fmt.Sprintf("error getting AWS session when fetching EC2 client: error reading options in NewCredentialsConfig: %s", testOptionErr))
|
||||
}
|
||||
|
||||
func TestAwsCatalogPersistedStateEC2ClientGood(t *testing.T) {
|
||||
require := require.New(t)
|
||||
state, err := newAwsCatalogPersistedState(
|
||||
withTestEC2APIFunc(newTestMockEC2(nil)),
|
||||
)
|
||||
require.NoError(err)
|
||||
|
||||
clientRaw, err := state.EC2Client(WithRegion("someregion"))
|
||||
require.NoError(err)
|
||||
require.NotNil(clientRaw)
|
||||
client, ok := clientRaw.(*testMockEC2)
|
||||
require.True(ok)
|
||||
require.NotNil(client)
|
||||
require.Equal("someregion", client.Region)
|
||||
}
|
||||
Vendored
-104
@@ -1,104 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
variable "iam_user_prefix" {
|
||||
default = "boundary-plugin-host-aws-test-iam-user"
|
||||
}
|
||||
|
||||
variable "iam_user_count" {
|
||||
default = 6
|
||||
}
|
||||
|
||||
resource "random_id" "aws_iam_user_name" {
|
||||
count = var.iam_user_count
|
||||
prefix = "${var.iam_user_prefix}-${count.index}"
|
||||
byte_length = 4
|
||||
}
|
||||
|
||||
resource "aws_iam_user" "user" {
|
||||
count = var.iam_user_count
|
||||
name = random_id.aws_iam_user_name[count.index].dec
|
||||
force_destroy = true
|
||||
|
||||
tags = var.project_path_tags
|
||||
}
|
||||
|
||||
resource "aws_iam_access_key" "user_initial_key" {
|
||||
count = var.iam_user_count
|
||||
user = aws_iam_user.user[count.index].name
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "ec2_describeinstances" {
|
||||
name = "BoundaryPluginHostAwsTestDescribeInstancesPolicy"
|
||||
|
||||
policy = <<EOF
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"ec2:DescribeInstances"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
tags = var.project_path_tags
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment" {
|
||||
count = var.iam_user_count
|
||||
user = aws_iam_user.user[count.index].name
|
||||
policy_arn = aws_iam_policy.ec2_describeinstances.arn
|
||||
}
|
||||
|
||||
resource "aws_iam_policy" "user_self_manage_policy" {
|
||||
count = var.iam_user_count
|
||||
name = "BoundaryPluginHostAwsTestUserSelfManagePolicy-${count.index}"
|
||||
|
||||
policy = <<EOF
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"iam:DeleteAccessKey",
|
||||
"iam:GetUser",
|
||||
"iam:CreateAccessKey"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Resource": "${aws_iam_user.user[count.index].arn}"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
tags = var.project_path_tags
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy_attachment" "user_self_manage_policy_attachment" {
|
||||
count = var.iam_user_count
|
||||
user = aws_iam_user.user[count.index].name
|
||||
policy_arn = aws_iam_policy.user_self_manage_policy[count.index].arn
|
||||
}
|
||||
|
||||
output "iam_user_names" {
|
||||
value = aws_iam_user.user.*.name
|
||||
}
|
||||
|
||||
output "iam_user_arns" {
|
||||
value = aws_iam_user.user.*.arn
|
||||
}
|
||||
|
||||
output "iam_access_key_ids" {
|
||||
value = aws_iam_access_key.user_initial_key.*.id
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "iam_secret_access_keys" {
|
||||
value = aws_iam_access_key.user_initial_key.*.secret
|
||||
sensitive = true
|
||||
}
|
||||
Vendored
-17
@@ -1,17 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "project_path_tags" {
|
||||
default = {
|
||||
"project_path" = "hashicorp/boundary-plugin-host-aws/test/basic"
|
||||
}
|
||||
}
|
||||
Vendored
-81
@@ -1,81 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
variable "instance_tags" {
|
||||
default = [
|
||||
{
|
||||
"foo" = "true",
|
||||
},
|
||||
{
|
||||
"foo" = "true",
|
||||
"bar" = "true",
|
||||
},
|
||||
{
|
||||
"bar" = "true",
|
||||
},
|
||||
{
|
||||
"bar" = "true",
|
||||
"baz" = "true",
|
||||
},
|
||||
{
|
||||
"baz" = "true",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "azs" {}
|
||||
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
|
||||
owners = ["099720109477"] # Canonical
|
||||
}
|
||||
|
||||
resource "aws_vpc" "vpc" {
|
||||
cidr_block = "10.0.0.0/16"
|
||||
|
||||
tags = var.project_path_tags
|
||||
}
|
||||
|
||||
resource "aws_subnet" "subnet" {
|
||||
vpc_id = aws_vpc.vpc.id
|
||||
cidr_block = aws_vpc.vpc.cidr_block
|
||||
availability_zone = data.aws_availability_zones.azs.names[0]
|
||||
|
||||
tags = var.project_path_tags
|
||||
}
|
||||
|
||||
resource "aws_instance" "instances" {
|
||||
count = length(var.instance_tags)
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = "t3.nano"
|
||||
subnet_id = aws_subnet.subnet.id
|
||||
|
||||
tags = merge(var.instance_tags[count.index], var.project_path_tags)
|
||||
}
|
||||
|
||||
output "instance_ids" {
|
||||
value = aws_instance.instances.*.id
|
||||
}
|
||||
|
||||
output "instance_addrs" {
|
||||
value = {
|
||||
for _, r in aws_instance.instances : r.id => r.private_ip
|
||||
}
|
||||
}
|
||||
|
||||
output "instance_tags" {
|
||||
value = {
|
||||
for _, r in aws_instance.instances : r.id => r.tags
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user