Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/pkg/aws/ssm/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions internal/pkg/aws/ssm/ssm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/pkg/aws/ssm/ssm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 10 additions & 2 deletions internal/pkg/cli/svc_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -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"
Expand Down Expand Up @@ -71,6 +74,8 @@ type deploySvcOpts struct {
newSvcDeployer func() (workloadDeployer, error)
svcVersionGetter versionGetter
envFeaturesDescriber versionCompatibilityChecker
ssmParamGetter secretGetter
secretsmanager secretDeleter
diffWriter io.Writer

spinner progress
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
128 changes: 128 additions & 0 deletions internal/pkg/cli/svc_deploy_secrets.go
Original file line number Diff line number Diff line change
@@ -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, &notFound) {
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, &notFound) {
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
}
125 changes: 125 additions & 0 deletions internal/pkg/cli/svc_deploy_secrets_test.go
Original file line number Diff line number Diff line change
@@ -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()
}