diff --git a/internal/pkg/aws/ssm/errors.go b/internal/pkg/aws/ssm/errors.go index 08689648ea5..0d8928ee4d7 100644 --- a/internal/pkg/aws/ssm/errors.go +++ b/internal/pkg/aws/ssm/errors.go @@ -13,3 +13,17 @@ type ErrParameterAlreadyExists struct { func (e *ErrParameterAlreadyExists) Error() string { return fmt.Sprintf("parameter %s already exists", e.name) } + +// ErrParameterNotFound occurs when the parameter with name does not exist. +type ErrParameterNotFound struct { + name string + parentErr error +} + +func (e *ErrParameterNotFound) Error() string { + return fmt.Sprintf("parameter %s does not exist", e.name) +} + +func (e *ErrParameterNotFound) Unwrap() error { + return e.parentErr +} diff --git a/internal/pkg/aws/ssm/ssm.go b/internal/pkg/aws/ssm/ssm.go index ddc00996c62..b1bdbf755bc 100644 --- a/internal/pkg/aws/ssm/ssm.go +++ b/internal/pkg/aws/ssm/ssm.go @@ -72,6 +72,9 @@ func (s *SSM) GetSecretValue(ctx context.Context, name string) (string, error) { WithDecryption: aws.Bool(true), }) if err != nil { + if aerr, ok := err.(awserr.Error); ok && aerr.Code() == ssm.ErrCodeParameterNotFound { + return "", &ErrParameterNotFound{name: name, parentErr: err} + } return "", fmt.Errorf("get parameter %q from SSM: %w", name, err) } return aws.StringValue(resp.Parameter.Value), nil diff --git a/internal/pkg/aws/ssm/ssm_test.go b/internal/pkg/aws/ssm/ssm_test.go index 6c032ac6b27..31d7bf0e762 100644 --- a/internal/pkg/aws/ssm/ssm_test.go +++ b/internal/pkg/aws/ssm/ssm_test.go @@ -377,6 +377,16 @@ func TestSSM_GetSecretValue(t *testing.T) { }, wantError: `get parameter "asdf" from SSM: some error`, }, + "parameter not found": { + secretName: "missing", + setupMock: func(m *mocks.Mockapi) { + m.EXPECT().GetParameterWithContext(gomock.Any(), &ssm.GetParameterInput{ + Name: aws.String("missing"), + WithDecryption: aws.Bool(true), + }).Return(nil, awserr.New(ssm.ErrCodeParameterNotFound, "parameter not found", nil)) + }, + wantError: `parameter missing does not exist`, + }, "success": { secretName: "asdf", setupMock: func(m *mocks.Mockapi) { diff --git a/internal/pkg/cli/svc_deploy.go b/internal/pkg/cli/svc_deploy.go index 41eebaba21d..161e5e82527 100644 --- a/internal/pkg/cli/svc_deploy.go +++ b/internal/pkg/cli/svc_deploy.go @@ -4,6 +4,7 @@ package cli import ( + "context" "errors" "fmt" "io" @@ -13,10 +14,12 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ssm" + sdkssm "github.com/aws/aws-sdk-go/service/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" awscfn "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" "github.com/aws/copilot-cli/internal/pkg/aws/identity" + awssecretsmanager "github.com/aws/copilot-cli/internal/pkg/aws/secretsmanager" + awsssm "github.com/aws/copilot-cli/internal/pkg/aws/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/tags" deploycfn "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation" "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" @@ -71,6 +74,8 @@ type deploySvcOpts struct { newSvcDeployer func() (workloadDeployer, error) svcVersionGetter versionGetter envFeaturesDescriber versionCompatibilityChecker + ssmParamGetter secretGetter + secretsmanager secretDeleter diffWriter io.Writer spinner progress @@ -105,7 +110,7 @@ func newSvcDeployOpts(vars deployWkldVars) (*deploySvcOpts, error) { return nil, err } - store := config.NewSSMStore(identity.New(defaultSession), ssm.New(defaultSession), aws.StringValue(defaultSession.Config.Region)) + store := config.NewSSMStore(identity.New(defaultSession), sdkssm.New(defaultSession), aws.StringValue(defaultSession.Config.Region)) prompter := prompt.New() opts := &deploySvcOpts{ @@ -238,6 +243,7 @@ func (o *deploySvcOpts) Execute() error { if err := validateWorkloadManifestCompatibilityWithEnv(o.ws, o.envFeaturesDescriber, mft, o.envName); err != nil { return err } + o.warnMissingSecrets(context.Background(), mft.Manifest()) deployer, err := o.newSvcDeployer() if err != nil { return err @@ -451,6 +457,8 @@ func (o *deploySvcOpts) configureClients() error { return err } o.envSess = envSess + o.ssmParamGetter = awsssm.New(envSess) + o.secretsmanager = awssecretsmanager.New(envSess) // client to retrieve caller identity. caller, err := identity.New(defaultSess).Get() diff --git a/internal/pkg/cli/svc_deploy_secrets.go b/internal/pkg/cli/svc_deploy_secrets.go new file mode 100644 index 00000000000..3aa06f7f904 --- /dev/null +++ b/internal/pkg/cli/svc_deploy_secrets.go @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "errors" + "sort" + "strings" + + awssecretsmanager "github.com/aws/copilot-cli/internal/pkg/aws/secretsmanager" + awsssm "github.com/aws/copilot-cli/internal/pkg/aws/ssm" + "github.com/aws/copilot-cli/internal/pkg/manifest" + "github.com/aws/copilot-cli/internal/pkg/term/color" + "github.com/aws/copilot-cli/internal/pkg/term/log" +) + +const ( + secretStoreSSM = "SSM Parameter Store" + secretStoreSecretsManager = "Secrets Manager" +) + +type missingSecret struct { + name string + store string +} + +func (o *deploySvcOpts) warnMissingSecrets(ctx context.Context, mft any) { + for _, secret := range o.missingSecrets(ctx, mft) { + log.Warningf("%s secret %s does not exist; deployment may fail until it is created.\n", + secret.store, color.HighlightUserInput(secret.name)) + } +} + +func (o *deploySvcOpts) missingSecrets(ctx context.Context, mft any) []missingSecret { + var missing []missingSecret + seen := make(map[missingSecret]struct{}) + for _, secret := range workloadSecrets(mft) { + name, store, ok := secretRef(secret) + if !ok { + continue + } + ref := missingSecret{name: name, store: store} + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + switch store { + case secretStoreSecretsManager: + if o.secretsmanager == nil { + continue + } + if _, err := o.secretsmanager.DescribeSecret(name); err != nil { + var notFound *awssecretsmanager.ErrSecretNotFound + if errors.As(err, ¬Found) { + missing = append(missing, ref) + } + } + default: + if o.ssmParamGetter == nil { + continue + } + if _, err := o.ssmParamGetter.GetSecretValue(ctx, name); err != nil { + var notFound *awsssm.ErrParameterNotFound + if errors.As(err, ¬Found) { + missing = append(missing, ref) + } + } + } + } + sort.Slice(missing, func(i, j int) bool { + if missing[i].store == missing[j].store { + return missing[i].name < missing[j].name + } + return missing[i].store < missing[j].store + }) + return missing +} + +func secretRef(secret manifest.Secret) (string, string, bool) { + if secret.RequiresImport() { + return "", "", false + } + name := secret.Value() + if name == "" { + return "", "", false + } + if secret.IsSecretsManagerName() || strings.Contains(name, ":secretsmanager:") { + return name, secretStoreSecretsManager, true + } + return name, secretStoreSSM, true +} + +func workloadSecrets(mft any) []manifest.Secret { + switch mft := mft.(type) { + case *manifest.LoadBalancedWebService: + return ecsWorkloadSecrets(mft.TaskConfig, mft.Logging, mft.Sidecars) + case *manifest.BackendService: + return ecsWorkloadSecrets(mft.TaskConfig, mft.Logging, mft.Sidecars) + case *manifest.WorkerService: + return ecsWorkloadSecrets(mft.TaskConfig, mft.Logging, mft.Sidecars) + case *manifest.RequestDrivenWebService: + return appendSecrets(nil, mft.Secrets) + default: + return nil + } +} + +func ecsWorkloadSecrets(task manifest.TaskConfig, logging manifest.Logging, sidecars map[string]*manifest.SidecarConfig) []manifest.Secret { + secrets := appendSecrets(nil, task.Secrets) + secrets = appendSecrets(secrets, logging.Secrets) + secrets = appendSecrets(secrets, logging.SecretOptions) + for _, sidecar := range sidecars { + if sidecar == nil { + continue + } + secrets = appendSecrets(secrets, sidecar.Secrets) + } + return secrets +} + +func appendSecrets(dst []manifest.Secret, src map[string]manifest.Secret) []manifest.Secret { + for _, secret := range src { + dst = append(dst, secret) + } + return dst +} diff --git a/internal/pkg/cli/svc_deploy_secrets_test.go b/internal/pkg/cli/svc_deploy_secrets_test.go new file mode 100644 index 00000000000..9e2709a2f35 --- /dev/null +++ b/internal/pkg/cli/svc_deploy_secrets_test.go @@ -0,0 +1,125 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "errors" + "testing" + + awssecretsmanager "github.com/aws/copilot-cli/internal/pkg/aws/secretsmanager" + awsssm "github.com/aws/copilot-cli/internal/pkg/aws/ssm" + "github.com/aws/copilot-cli/internal/pkg/cli/mocks" + "github.com/aws/copilot-cli/internal/pkg/manifest" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestDeploySvcOpts_MissingSecrets(t *testing.T) { + testCases := map[string]struct { + inManifest string + setupMocks func(m *deploySvcSecretMocks) + + wanted []missingSecret + }{ + "returns SSM and Secrets Manager refs that do not exist": { + inManifest: ` +name: frontend +type: "Load Balanced Web Service" +image: + location: nginx + port: 80 +secrets: + DB_PASSWORD: /copilot/phonetool/test/secrets/db-password +logging: + secretOptions: + LOG_TOKEN: /copilot/phonetool/test/secrets/log-token +sidecars: + xray: + image: xray-daemon + secrets: + API_TOKEN: + secretsmanager: sidecar-secret +`, + setupMocks: func(m *deploySvcSecretMocks) { + m.ssm.EXPECT().GetSecretValue(gomock.Any(), "/copilot/phonetool/test/secrets/db-password"). + Return("", &awsssm.ErrParameterNotFound{}) + m.ssm.EXPECT().GetSecretValue(gomock.Any(), "/copilot/phonetool/test/secrets/log-token"). + Return("log-token", nil) + m.secretsmanager.EXPECT().DescribeSecret("sidecar-secret"). + Return(nil, &awssecretsmanager.ErrSecretNotFound{}) + }, + wanted: []missingSecret{ + {name: "/copilot/phonetool/test/secrets/db-password", store: secretStoreSSM}, + {name: "sidecar-secret", store: secretStoreSecretsManager}, + }, + }, + "skips imported secrets and ignores validation errors that are not not-found errors": { + inManifest: ` +name: frontend +type: "Backend Service" +image: + location: nginx +secrets: + IMPORTED: + from_cfn: stack-SecretName + API_KEY: /copilot/phonetool/test/secrets/api-key +`, + setupMocks: func(m *deploySvcSecretMocks) { + m.ssm.EXPECT().GetSecretValue(gomock.Any(), "/copilot/phonetool/test/secrets/api-key"). + Return("", errors.New("access denied")) + }, + }, + "deduplicates repeated refs": { + inManifest: ` +name: frontend +type: "Request-Driven Web Service" +image: + location: nginx +secrets: + FIRST: shared-secret + SECOND: shared-secret +`, + setupMocks: func(m *deploySvcSecretMocks) { + m.ssm.EXPECT().GetSecretValue(gomock.Any(), "shared-secret"). + Return("", &awsssm.ErrParameterNotFound{}) + }, + wanted: []missingSecret{ + {name: "shared-secret", store: secretStoreSSM}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClients := deploySvcSecretMocks{ + ssm: mocks.NewMocksecretGetter(ctrl), + secretsmanager: mocks.NewMocksecretDeleter(ctrl), + } + tc.setupMocks(&mockClients) + + opts := deploySvcOpts{ + ssmParamGetter: mockClients.ssm, + secretsmanager: mockClients.secretsmanager, + } + require.ElementsMatch(t, tc.wanted, opts.missingSecrets(context.Background(), mustUnmarshalWorkload(t, tc.inManifest))) + }) + } +} + +type deploySvcSecretMocks struct { + ssm *mocks.MocksecretGetter + secretsmanager *mocks.MocksecretDeleter +} + +func mustUnmarshalWorkload(t *testing.T, raw string) any { + t.Helper() + + mft, err := manifest.UnmarshalWorkload([]byte(raw)) + require.NoError(t, err) + return mft.Manifest() +}