diff --git a/internal/pkg/aws/secretsmanager/mocks/mock_secretsmanager.go b/internal/pkg/aws/secretsmanager/mocks/mock_secretsmanager.go index 86a731505eb..c1e09ba1cb3 100644 --- a/internal/pkg/aws/secretsmanager/mocks/mock_secretsmanager.go +++ b/internal/pkg/aws/secretsmanager/mocks/mock_secretsmanager.go @@ -78,3 +78,18 @@ func (mr *MockapiMockRecorder) DescribeSecret(input interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeSecret", reflect.TypeOf((*Mockapi)(nil).DescribeSecret), input) } + +// GetSecretValue mocks base method. +func (m *Mockapi) GetSecretValue(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSecretValue", input) + ret0, _ := ret[0].(*secretsmanager.GetSecretValueOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSecretValue indicates an expected call of GetSecretValue. +func (mr *MockapiMockRecorder) GetSecretValue(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretValue", reflect.TypeOf((*Mockapi)(nil).GetSecretValue), input) +} diff --git a/internal/pkg/aws/secretsmanager/secretsmanager.go b/internal/pkg/aws/secretsmanager/secretsmanager.go index 7cb95ba59fd..a735345a24d 100644 --- a/internal/pkg/aws/secretsmanager/secretsmanager.go +++ b/internal/pkg/aws/secretsmanager/secretsmanager.go @@ -15,10 +15,14 @@ import ( "github.com/aws/aws-sdk-go/service/secretsmanager" ) +// Namespace represents the AWS Secrets Manager service namespace. +const Namespace = "secretsmanager" + type api interface { CreateSecret(*secretsmanager.CreateSecretInput) (*secretsmanager.CreateSecretOutput, error) DeleteSecret(*secretsmanager.DeleteSecretInput) (*secretsmanager.DeleteSecretOutput, error) DescribeSecret(input *secretsmanager.DescribeSecretInput) (*secretsmanager.DescribeSecretOutput, error) + GetSecretValue(input *secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) } // SecretsManager wraps the AWS SecretManager client. @@ -114,6 +118,18 @@ func (s *SecretsManager) DescribeSecret(secretName string) (*DescribeSecretOutpu }, nil } +// GetSecretValue retrieves the value of a secret from AWS Secrets Manager. +// It takes the name of the secret as input and returns the corresponding value as a string. +func (s *SecretsManager) GetSecretValue(name string) (string, error) { + resp, err := s.secretsManager.GetSecretValue(&secretsmanager.GetSecretValueInput{ + SecretId: aws.String(name), + }) + if err != nil { + return "", fmt.Errorf("get secret %q from secrets manager: %w", name, err) + } + return aws.StringValue(resp.SecretString), nil +} + // ErrSecretAlreadyExists occurs if a secret with the same name already exists. type ErrSecretAlreadyExists struct { secretName string diff --git a/internal/pkg/aws/ssm/mocks/mock_ssm.go b/internal/pkg/aws/ssm/mocks/mock_ssm.go index e470c30a1a2..09dda821f97 100644 --- a/internal/pkg/aws/ssm/mocks/mock_ssm.go +++ b/internal/pkg/aws/ssm/mocks/mock_ssm.go @@ -49,6 +49,21 @@ func (mr *MockapiMockRecorder) AddTagsToResource(input interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTagsToResource", reflect.TypeOf((*Mockapi)(nil).AddTagsToResource), input) } +// GetParameter mocks base method. +func (m *Mockapi) GetParameter(input *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetParameter", input) + ret0, _ := ret[0].(*ssm.GetParameterOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetParameter indicates an expected call of GetParameter. +func (mr *MockapiMockRecorder) GetParameter(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParameter", reflect.TypeOf((*Mockapi)(nil).GetParameter), input) +} + // PutParameter mocks base method. func (m *Mockapi) PutParameter(input *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/aws/ssm/ssm.go b/internal/pkg/aws/ssm/ssm.go index d8096e2d7c2..d5ac6c66395 100644 --- a/internal/pkg/aws/ssm/ssm.go +++ b/internal/pkg/aws/ssm/ssm.go @@ -16,9 +16,13 @@ import ( "github.com/aws/aws-sdk-go/service/ssm" ) +// Namespace represents the AWS Systems Manager(SSM) service namespace. +const Namespace = "ssm" + type api interface { PutParameter(input *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) AddTagsToResource(input *ssm.AddTagsToResourceInput) (*ssm.AddTagsToResourceOutput, error) + GetParameter(input *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) } // SSM wraps an AWS SSM client. @@ -61,6 +65,19 @@ func (s *SSM) PutSecret(in PutSecretInput) (*PutSecretOutput, error) { return nil, err } +// GetSecretValue retrieves the value of a parameter from AWS Systems Manager Parameter Store. +// It takes the name of the parameter as input and returns the corresponding value as a string. +func (s *SSM) GetSecretValue(name string) (string, error) { + resp, err := s.client.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(name), + WithDecryption: aws.Bool(true), + }) + if err != nil { + return "", fmt.Errorf("get parameter %q from SSM: %w", name, err) + } + return aws.StringValue(resp.Parameter.Value), nil +} + func (s *SSM) createSecret(in PutSecretInput) (*PutSecretOutput, error) { // Create a secret while adding the tags in a single call instead of separate calls to `PutParameter` and // `AddTagsToResource` so that there won't be a case where the parameter is created while the tags are not added. diff --git a/internal/pkg/cli/interfaces.go b/internal/pkg/cli/interfaces.go index c1c33fbc4f1..cd497fbee01 100644 --- a/internal/pkg/cli/interfaces.go +++ b/internal/pkg/cli/interfaces.go @@ -181,6 +181,11 @@ type repositoryService interface { imageBuilderPusher } +type ecsLocalClient interface { + TaskDefinition(app, env, svc string) (*awsecs.TaskDefinition, error) + DecryptedSecrets(secrets []*awsecs.ContainerSecret) ([]ecs.EnvVar, error) +} + type logEventsWriter interface { WriteLogEvents(opts logging.WriteLogEventsOpts) error } diff --git a/internal/pkg/cli/local_run.go b/internal/pkg/cli/local_run.go index 9fc9129bf00..410b82ebfac 100644 --- a/internal/pkg/cli/local_run.go +++ b/internal/pkg/cli/local_run.go @@ -7,11 +7,13 @@ import ( "fmt" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/identity" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" "github.com/aws/copilot-cli/internal/pkg/config" "github.com/aws/copilot-cli/internal/pkg/deploy" + "github.com/aws/copilot-cli/internal/pkg/ecs" "github.com/aws/copilot-cli/internal/pkg/term/prompt" "github.com/aws/copilot-cli/internal/pkg/term/selector" "github.com/spf13/cobra" @@ -29,8 +31,10 @@ type localRunVars struct { type localRunOpts struct { localRunVars - sel deploySelector - store store + sel deploySelector + ecsLocalClient ecsLocalClient + sess *session.Session + store store } func newLocalRunOpts(vars localRunVars) (*localRunOpts, error) { @@ -46,9 +50,11 @@ func newLocalRunOpts(vars localRunVars) (*localRunOpts, error) { return nil, err } opts := &localRunOpts{ - localRunVars: vars, - sel: selector.NewDeploySelect(prompt.New(), store, deployStore), - store: store, + localRunVars: vars, + sel: selector.NewDeploySelect(prompt.New(), store, deployStore), + store: store, + ecsLocalClient: ecs.New(defaultSess), + sess: defaultSess, } return opts, nil } @@ -94,7 +100,16 @@ func (o *localRunOpts) validateAndAskWkldEnvName() error { // Execute builds and runs the workload images locally. func (o *localRunOpts) Execute() error { - //TODO(varun359): Get build information from the manifest and task definition for workloads + taskDef, err := o.ecsLocalClient.TaskDefinition(o.appName, o.envName, o.wkldName) + if err != nil { + return fmt.Errorf("get task definition: %w", err) + } + + secrets := taskDef.Secrets() + _, err = o.ecsLocalClient.DecryptedSecrets(secrets) + if err != nil { + return fmt.Errorf("get secret values: %w", err) + } return nil } diff --git a/internal/pkg/cli/local_run_test.go b/internal/pkg/cli/local_run_test.go index c8d1f4e0444..425272b6ace 100644 --- a/internal/pkg/cli/local_run_test.go +++ b/internal/pkg/cli/local_run_test.go @@ -8,8 +8,12 @@ import ( "fmt" "testing" + "github.com/aws/aws-sdk-go/aws" + ecsapi "github.com/aws/aws-sdk-go/service/ecs" + awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" "github.com/aws/copilot-cli/internal/pkg/cli/mocks" "github.com/aws/copilot-cli/internal/pkg/config" + "github.com/aws/copilot-cli/internal/pkg/ecs" "github.com/aws/copilot-cli/internal/pkg/term/selector" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" @@ -187,3 +191,109 @@ func TestLocalRunOpts_Ask(t *testing.T) { }) } } + +type localRunExecuteMocks struct { + ecsLocalClient *mocks.MockecsLocalClient +} + +func TestLocalRunOpts_Execute(t *testing.T) { + const ( + testAppName = "testApp" + testEnvName = "testEnv" + testWkldName = "testWkld" + testWkldType = "testWkldType" + ) + var taskDefinition = &awsecs.TaskDefinition{ + ContainerDefinitions: []*ecsapi.ContainerDefinition{ + { + Name: aws.String("container"), + Environment: []*ecsapi.KeyValuePair{ + { + Name: aws.String("COPILOT_SERVICE_NAME"), + Value: aws.String("testWkld"), + }, + { + Name: aws.String("COPILOT_ENVIRONMENT_NAME"), + Value: aws.String("testEnv"), + }, + }, + }, + }, + } + testCases := map[string]struct { + inputAppName string + inputEnvName string + inputWkldName string + + setupMocks func(m *localRunExecuteMocks) + wantedWkldName string + wantedEnvName string + wantedWkldType string + wantedError error + }{ + "error getting the task Definition": { + inputAppName: testAppName, + inputWkldName: testWkldName, + inputEnvName: testEnvName, + setupMocks: func(m *localRunExecuteMocks) { + m.ecsLocalClient.EXPECT().TaskDefinition(testAppName, testEnvName, testWkldName).Return(nil, testError) + }, + wantedError: fmt.Errorf("get task definition: %w", testError), + }, + "error decryting secrets from task definition": { + inputAppName: testAppName, + inputWkldName: testWkldName, + inputEnvName: testEnvName, + setupMocks: func(m *localRunExecuteMocks) { + m.ecsLocalClient.EXPECT().TaskDefinition(testAppName, testEnvName, testWkldName).Return(taskDefinition, nil) + m.ecsLocalClient.EXPECT().DecryptedSecrets(gomock.Any()).Return(nil, testError) + }, + wantedError: fmt.Errorf("get secret values: %w", testError), + }, + "success decrypting secrets from task definition": { + inputAppName: testAppName, + inputWkldName: testWkldName, + inputEnvName: testEnvName, + setupMocks: func(m *localRunExecuteMocks) { + m.ecsLocalClient.EXPECT().TaskDefinition(testAppName, testEnvName, testWkldName).Return(taskDefinition, nil) + m.ecsLocalClient.EXPECT().DecryptedSecrets(gomock.Any()).Return([]ecs.EnvVar{{ + Name: "my-secret", + Value: "Password123", + }, { + Name: "secret2", + Value: "admin123", + }, + }, nil) + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + m := &localRunExecuteMocks{ + ecsLocalClient: mocks.NewMockecsLocalClient(ctrl), + } + tc.setupMocks(m) + opts := localRunOpts{ + localRunVars: localRunVars{ + appName: tc.inputAppName, + wkldName: tc.inputWkldName, + envName: tc.inputEnvName, + }, + ecsLocalClient: m.ecsLocalClient, + } + + // WHEN + err := opts.Execute() + + // THEN + if tc.wantedError == nil { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.wantedError.Error()) + } + }) + } +} diff --git a/internal/pkg/cli/mocks/mock_interfaces.go b/internal/pkg/cli/mocks/mock_interfaces.go index 9f4fcc40d1b..d5e73a4035c 100644 --- a/internal/pkg/cli/mocks/mock_interfaces.go +++ b/internal/pkg/cli/mocks/mock_interfaces.go @@ -1684,6 +1684,59 @@ func (mr *MockrepositoryServiceMockRecorder) Login() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Login", reflect.TypeOf((*MockrepositoryService)(nil).Login)) } +// MockecsLocalClient is a mock of ecsLocalClient interface. +type MockecsLocalClient struct { + ctrl *gomock.Controller + recorder *MockecsLocalClientMockRecorder +} + +// MockecsLocalClientMockRecorder is the mock recorder for MockecsLocalClient. +type MockecsLocalClientMockRecorder struct { + mock *MockecsLocalClient +} + +// NewMockecsLocalClient creates a new mock instance. +func NewMockecsLocalClient(ctrl *gomock.Controller) *MockecsLocalClient { + mock := &MockecsLocalClient{ctrl: ctrl} + mock.recorder = &MockecsLocalClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockecsLocalClient) EXPECT() *MockecsLocalClientMockRecorder { + return m.recorder +} + +// DecryptedSecrets mocks base method. +func (m *MockecsLocalClient) DecryptedSecrets(secrets []*ecs.ContainerSecret) ([]ecs0.EnvVar, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DecryptedSecrets", secrets) + ret0, _ := ret[0].([]ecs0.EnvVar) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DecryptedSecrets indicates an expected call of DecryptedSecrets. +func (mr *MockecsLocalClientMockRecorder) DecryptedSecrets(secrets interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DecryptedSecrets", reflect.TypeOf((*MockecsLocalClient)(nil).DecryptedSecrets), secrets) +} + +// TaskDefinition mocks base method. +func (m *MockecsLocalClient) TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TaskDefinition", app, env, svc) + ret0, _ := ret[0].(*ecs.TaskDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskDefinition indicates an expected call of TaskDefinition. +func (mr *MockecsLocalClientMockRecorder) TaskDefinition(app, env, svc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskDefinition", reflect.TypeOf((*MockecsLocalClient)(nil).TaskDefinition), app, env, svc) +} + // MocklogEventsWriter is a mock of logEventsWriter interface. type MocklogEventsWriter struct { ctrl *gomock.Controller diff --git a/internal/pkg/ecs/ecs.go b/internal/pkg/ecs/ecs.go index 598a5be5a1d..e3c4f71075a 100644 --- a/internal/pkg/ecs/ecs.go +++ b/internal/pkg/ecs/ecs.go @@ -11,13 +11,14 @@ import ( "strings" "time" - "github.com/aws/aws-sdk-go/aws/arn" - "github.com/aws/copilot-cli/internal/pkg/aws/stepfunctions" - "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/copilot-cli/internal/pkg/aws/ecs" "github.com/aws/copilot-cli/internal/pkg/aws/resourcegroups" + "github.com/aws/copilot-cli/internal/pkg/aws/secretsmanager" + "github.com/aws/copilot-cli/internal/pkg/aws/ssm" + "github.com/aws/copilot-cli/internal/pkg/aws/stepfunctions" "github.com/aws/copilot-cli/internal/pkg/deploy" ) @@ -53,6 +54,16 @@ type stepFunctionsClient interface { StateMachineDefinition(stateMachineARN string) (string, error) } +type secretGetter interface { + GetSecretValue(secretName string) (string, error) +} + +// EnvVar contains the value of an environment variable +type EnvVar struct { + Name string + Value string +} + // ServiceDesc contains the description of an ECS service. type ServiceDesc struct { Name string @@ -64,6 +75,8 @@ type ServiceDesc struct { // Client retrieves Copilot information from ECS endpoint. type Client struct { rgGetter resourceGetter + ssm secretGetter + secretManager secretGetter ecsClient ecsClient StepFuncClient stepFunctionsClient } @@ -73,6 +86,8 @@ func New(sess *session.Session) *Client { return &Client{ rgGetter: resourcegroups.New(sess), ecsClient: ecs.New(sess), + ssm: ssm.New(sess), + secretManager: secretsmanager.New(sess), StepFuncClient: stepfunctions.New(sess), } } @@ -232,6 +247,48 @@ func (c Client) StopDefaultClusterTasks(familyName string) error { return c.ecsClient.StopTasks(taskIDs, ecs.WithStopTaskReason(taskStopReason)) } +func secretNameSpace(secret string) (string, error) { + // A secret value can be a SSM Parameter Name/ SSM Parameter ARN/ Secrets Manager ARN + // Note: If there is an error while parsing the secret value, this functions assumes it to be SSM Parameter. + // Refer to https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Secret.html + parsed, err := arn.Parse(secret) + if err != nil { + return ssm.Namespace, nil + } + switch parsed.Service { + case ssm.Namespace, secretsmanager.Namespace: + return parsed.Service, nil + default: + return "", fmt.Errorf("invalid ARN: not an SSM or Secrets Manager ARN") + } +} + +// DecryptedSecrets returns the decrypted parameters from either SSM parameter store or Secrets Manager. +func (c Client) DecryptedSecrets(secrets []*ecs.ContainerSecret) ([]EnvVar, error) { + var vars []EnvVar + for _, secret := range secrets { + namespace, err := secretNameSpace(secret.ValueFrom) + if err != nil { + return nil, err + } + var secretValue string + switch namespace { + case ssm.Namespace: + secretValue, err = c.ssm.GetSecretValue(secret.ValueFrom) + case secretsmanager.Namespace: + secretValue, err = c.secretManager.GetSecretValue(secret.ValueFrom) + } + if err != nil { + return nil, err + } + vars = append(vars, EnvVar{ + Name: secret.Name, + Value: secretValue, + }) + } + return vars, nil +} + // TaskDefinition returns the task definition of the service. func (c Client) TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) { taskDefName := fmt.Sprintf("%s-%s-%s", app, env, svc) diff --git a/internal/pkg/ecs/mocks/mock_ecs.go b/internal/pkg/ecs/mocks/mock_ecs.go index 848f1989aca..a5b8f0df55f 100644 --- a/internal/pkg/ecs/mocks/mock_ecs.go +++ b/internal/pkg/ecs/mocks/mock_ecs.go @@ -302,3 +302,41 @@ func (mr *MockstepFunctionsClientMockRecorder) StateMachineDefinition(stateMachi mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StateMachineDefinition", reflect.TypeOf((*MockstepFunctionsClient)(nil).StateMachineDefinition), stateMachineARN) } + +// MocksecretGetter is a mock of secretGetter interface. +type MocksecretGetter struct { + ctrl *gomock.Controller + recorder *MocksecretGetterMockRecorder +} + +// MocksecretGetterMockRecorder is the mock recorder for MocksecretGetter. +type MocksecretGetterMockRecorder struct { + mock *MocksecretGetter +} + +// NewMocksecretGetter creates a new mock instance. +func NewMocksecretGetter(ctrl *gomock.Controller) *MocksecretGetter { + mock := &MocksecretGetter{ctrl: ctrl} + mock.recorder = &MocksecretGetterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksecretGetter) EXPECT() *MocksecretGetterMockRecorder { + return m.recorder +} + +// GetSecretValue mocks base method. +func (m *MocksecretGetter) GetSecretValue(secretName string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSecretValue", secretName) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSecretValue indicates an expected call of GetSecretValue. +func (mr *MocksecretGetterMockRecorder) GetSecretValue(secretName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretValue", reflect.TypeOf((*MocksecretGetter)(nil).GetSecretValue), secretName) +}