diff --git a/internal/pkg/aws/resourcegroups/resourcegroups.go b/internal/pkg/aws/resourcegroups/resourcegroups.go index c4157ec9a56..2486d319701 100644 --- a/internal/pkg/aws/resourcegroups/resourcegroups.go +++ b/internal/pkg/aws/resourcegroups/resourcegroups.go @@ -16,6 +16,8 @@ import ( const ( // ResourceTypeStateMachine is the resource type for the state machine of a job. ResourceTypeStateMachine = "states:stateMachine" + // ResourceTypeRDS is the resource type for any rds resources. + ResourceTypeRDS = "rds" ) type api interface { diff --git a/internal/pkg/cli/run_local.go b/internal/pkg/cli/run_local.go index eddbc9746ab..712e86c9710 100644 --- a/internal/pkg/cli/run_local.go +++ b/internal/pkg/cli/run_local.go @@ -20,14 +20,17 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/aws/session" sdkecs "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/rds" sdksecretsmanager "github.com/aws/aws-sdk-go/service/secretsmanager" sdkssm "github.com/aws/aws-sdk-go/service/ssm" cmdtemplate "github.com/aws/copilot-cli/cmd/copilot/template" "github.com/aws/copilot-cli/internal/pkg/aws/ecr" awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" "github.com/aws/copilot-cli/internal/pkg/aws/identity" + "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/sessions" "github.com/aws/copilot-cli/internal/pkg/aws/ssm" @@ -72,6 +75,15 @@ type hostFinder interface { Hosts(context.Context) ([]orchestrator.Host, error) } +type taggedResourceGetter interface { + GetResourcesByTags(string, map[string]string) ([]*resourcegroups.Resource, error) +} + +type rdsDescriber interface { + DescribeDBInstancesPagesWithContext(context.Context, *rds.DescribeDBInstancesInput, func(*rds.DescribeDBInstancesOutput, bool) bool, ...request.Option) error + DescribeDBClustersPagesWithContext(context.Context, *rds.DescribeDBClustersInput, func(*rds.DescribeDBClustersOutput, bool) bool, ...request.Option) error +} + type recursiveWatcher interface { Add(path string) error Close() error @@ -195,6 +207,8 @@ func newRunLocalOpts(vars runLocalVars) (*runLocalOpts, error) { env: o.envName, wkld: o.wkldName, ecs: ecs.New(o.envManagerSess), + rg: resourcegroups.New(o.envManagerSess), + rds: rds.New(o.envManagerSess), } envDesc, err := describe.NewEnvDescriber(describe.NewEnvDescriberConfig{ App: o.appName, @@ -427,7 +441,7 @@ func (o *runLocalOpts) getTask(ctx context.Context) (orchestrator.Task, error) { } if o.proxy { - pauseSecrets, err := sessionEnvVars(ctx, o.sess) + pauseSecrets, err := sessionEnvVars(ctx, o.envManagerSess) if err != nil { return orchestrator.Task{}, fmt.Errorf("get pause container secrets: %w", err) } @@ -793,6 +807,9 @@ type hostDiscoverer struct { app string env string wkld string + + rg taggedResourceGetter + rds rdsDescriber } func (h *hostDiscoverer) Hosts(ctx context.Context) ([]orchestrator.Host, error) { @@ -815,12 +832,108 @@ func (h *hostDiscoverer) Hosts(ctx context.Context) ([]orchestrator.Host, error) for _, alias := range sc.ClientAliases { hosts = append(hosts, orchestrator.Host{ Name: aws.StringValue(alias.DnsName), - Port: strconv.Itoa(int(aws.Int64Value(alias.Port))), + Port: uint16(aws.Int64Value(alias.Port)), }) } } } + rdsHosts, err := h.rdsHosts(ctx) + if err != nil { + return nil, fmt.Errorf("get rds hosts: %w", err) + } + + return append(hosts, rdsHosts...), nil +} + +// rdsHosts gets rds endpoints for workloads tagged for this workload +// or for the environment using direct AWS SDK calls. +func (h *hostDiscoverer) rdsHosts(ctx context.Context) ([]orchestrator.Host, error) { + var hosts []orchestrator.Host + + resources, err := h.rg.GetResourcesByTags(resourcegroups.ResourceTypeRDS, map[string]string{ + deploy.AppTagKey: h.app, + deploy.EnvTagKey: h.env, + }) + switch { + case err != nil: + return nil, fmt.Errorf("get tagged resources: %w", err) + case len(resources) == 0: + return nil, nil + } + + dbFilter := &rds.Filter{ + Name: aws.String("db-instance-id"), + } + clusterFilter := &rds.Filter{ + Name: aws.String("db-cluster-id"), + } + for i := range resources { + // we don't want resources that belong to other services + // but we do want env level services + if wkld, ok := resources[i].Tags[deploy.ServiceTagKey]; ok && wkld != h.wkld { + continue + } + + arn, err := arn.Parse(resources[i].ARN) + if err != nil { + return nil, fmt.Errorf("invalid arn %q: %w", resources[i].ARN, err) + } + + switch { + case strings.HasPrefix(arn.Resource, "db:"): + dbFilter.Values = append(dbFilter.Values, aws.String(resources[i].ARN)) + case strings.HasPrefix(arn.Resource, "cluster:"): + clusterFilter.Values = append(clusterFilter.Values, aws.String(resources[i].ARN)) + } + } + + if len(dbFilter.Values) > 0 { + err = h.rds.DescribeDBInstancesPagesWithContext(ctx, &rds.DescribeDBInstancesInput{ + Filters: []*rds.Filter{dbFilter}, + }, func(out *rds.DescribeDBInstancesOutput, lastPage bool) bool { + for _, db := range out.DBInstances { + if db.Endpoint != nil { + hosts = append(hosts, orchestrator.Host{ + Name: aws.StringValue(db.Endpoint.Address), + Port: uint16(aws.Int64Value(db.Endpoint.Port)), + }) + } + } + return true + }) + if err != nil { + return nil, fmt.Errorf("describe instances: %w", err) + } + } + + if len(clusterFilter.Values) > 0 { + err = h.rds.DescribeDBClustersPagesWithContext(ctx, &rds.DescribeDBClustersInput{ + Filters: []*rds.Filter{clusterFilter}, + }, func(out *rds.DescribeDBClustersOutput, lastPage bool) bool { + for _, db := range out.DBClusters { + add := func(s *string) { + if s != nil { + hosts = append(hosts, orchestrator.Host{ + Name: aws.StringValue(s), + Port: uint16(aws.Int64Value(db.Port)), + }) + } + } + + add(db.Endpoint) + add(db.ReaderEndpoint) + for i := range db.CustomEndpoints { + add(db.CustomEndpoints[i]) + } + } + return true + }) + if err != nil { + return nil, fmt.Errorf("describe clusters: %w", err) + } + } + return hosts, nil } diff --git a/internal/pkg/cli/run_local_test.go b/internal/pkg/cli/run_local_test.go index edc73a2e8f1..b300a50f22f 100644 --- a/internal/pkg/cli/run_local_test.go +++ b/internal/pkg/cli/run_local_test.go @@ -12,12 +12,16 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/aws/session" sdkecs "github.com/aws/aws-sdk-go/service/ecs" + "github.com/aws/aws-sdk-go/service/rds" awsecs "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/cli/file/filetest" "github.com/aws/copilot-cli/internal/pkg/cli/mocks" "github.com/aws/copilot-cli/internal/pkg/config" + "github.com/aws/copilot-cli/internal/pkg/deploy" "github.com/aws/copilot-cli/internal/pkg/docker/orchestrator" "github.com/aws/copilot-cli/internal/pkg/docker/orchestrator/orchestratortest" "github.com/aws/copilot-cli/internal/pkg/ecs" @@ -406,9 +410,9 @@ func TestRunLocalOpts_Execute(t *testing.T) { expectedProxyTask := orchestrator.Task{ Containers: expectedTask.Containers, PauseSecrets: map[string]string{ - "AWS_ACCESS_KEY_ID": "myID", - "AWS_SECRET_ACCESS_KEY": "mySecret", - "AWS_SESSION_TOKEN": "myToken", + "AWS_ACCESS_KEY_ID": "myEnvID", + "AWS_SECRET_ACCESS_KEY": "myEnvSecret", + "AWS_SESSION_TOKEN": "myEnvToken", }, } @@ -547,7 +551,7 @@ func TestRunLocalOpts_Execute(t *testing.T) { return []orchestrator.Host{ { Name: "a-different-service", - Port: "80", + Port: 80, }, }, nil } @@ -570,7 +574,7 @@ func TestRunLocalOpts_Execute(t *testing.T) { return []orchestrator.Host{ { Name: "a-different-service", - Port: "80", + Port: 80, }, }, nil } @@ -599,7 +603,7 @@ func TestRunLocalOpts_Execute(t *testing.T) { return []orchestrator.Host{ { Name: "a-different-service", - Port: "80", + Port: 80, }, }, nil } @@ -628,7 +632,7 @@ func TestRunLocalOpts_Execute(t *testing.T) { return []orchestrator.Host{ { Name: "a-different-service", - Port: "80", + Port: 80, }, }, nil } @@ -685,7 +689,7 @@ func TestRunLocalOpts_Execute(t *testing.T) { return []orchestrator.Host{ { Name: "a-different-service", - Port: "80", + Port: 80, }, }, nil } @@ -994,6 +998,11 @@ func TestRunLocalOpts_Execute(t *testing.T) { Credentials: credentials.NewStaticCredentials("myID", "mySecret", "myToken"), }, }, + envManagerSess: &session.Session{ + Config: &aws.Config{ + Credentials: credentials.NewStaticCredentials("myEnvID", "myEnvSecret", "myEnvToken"), + }, + }, cmd: m.mockRunner, dockerEngine: m.dockerEngine, repository: m.repository, @@ -1420,9 +1429,99 @@ func TestRunLocalOpts_getEnvVars(t *testing.T) { } } +type taggedResourceGetterDouble struct { + GetResourcesByTagsFn func(string, map[string]string) ([]*resourcegroups.Resource, error) +} + +func (d *taggedResourceGetterDouble) GetResourcesByTags(resourceType string, tags map[string]string) ([]*resourcegroups.Resource, error) { + if d.GetResourcesByTagsFn == nil { + return nil, nil + } + return d.GetResourcesByTagsFn(resourceType, tags) +} + +type rdsDescriberDouble struct { + DescribeDBInstancesPagesWithContextFn func(context.Context, *rds.DescribeDBInstancesInput, func(*rds.DescribeDBInstancesOutput, bool) bool, ...request.Option) error + DescribeDBClustersPagesWithContextFn func(context.Context, *rds.DescribeDBClustersInput, func(*rds.DescribeDBClustersOutput, bool) bool, ...request.Option) error +} + +func (d *rdsDescriberDouble) DescribeDBInstancesPagesWithContext(ctx context.Context, in *rds.DescribeDBInstancesInput, fn func(*rds.DescribeDBInstancesOutput, bool) bool, opts ...request.Option) error { + if d.DescribeDBInstancesPagesWithContextFn == nil { + return nil + } + return d.DescribeDBInstancesPagesWithContextFn(ctx, in, fn, opts...) +} + +func (d *rdsDescriberDouble) DescribeDBClustersPagesWithContext(ctx context.Context, in *rds.DescribeDBClustersInput, fn func(*rds.DescribeDBClustersOutput, bool) bool, opts ...request.Option) error { + if d.DescribeDBClustersPagesWithContextFn == nil { + return nil + } + return d.DescribeDBClustersPagesWithContextFn(ctx, in, fn, opts...) +} + func TestRunLocal_HostDiscovery(t *testing.T) { type testMocks struct { ecs *mocks.MockecsClient + rg *taggedResourceGetterDouble + rds *rdsDescriberDouble + } + ecsServices := []*awsecs.Service{ + { + Deployments: []*sdkecs.Deployment{ + { + Status: aws.String("ACTIVE"), + ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ + Enabled: aws.Bool(true), + Services: []*sdkecs.ServiceConnectService{ + { + ClientAliases: []*sdkecs.ServiceConnectClientAlias{ + { + DnsName: aws.String("old"), + Port: aws.Int64(80), + }, + }, + }, + }, + }, + }, + { + Status: aws.String("PRIMARY"), + ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ + Enabled: aws.Bool(true), + Services: []*sdkecs.ServiceConnectService{ + { + ClientAliases: []*sdkecs.ServiceConnectClientAlias{ + { + DnsName: aws.String("primary"), + Port: aws.Int64(80), + }, + }, + }, + }, + }, + }, + }, + }, + { + Deployments: []*sdkecs.Deployment{ + { + Status: aws.String("INACTIVE"), + ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ + Enabled: aws.Bool(true), + Services: []*sdkecs.ServiceConnectService{ + { + ClientAliases: []*sdkecs.ServiceConnectClientAlias{ + { + DnsName: aws.String("inactive"), + Port: aws.Int64(80), + }, + }, + }, + }, + }, + }, + }, + }, } tests := map[string]struct { @@ -1439,69 +1538,214 @@ func TestRunLocal_HostDiscovery(t *testing.T) { }, "ignores non-primary deployments": { setupMocks: func(t *testing.T, m *testMocks) { - m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*awsecs.Service{ - { - Deployments: []*sdkecs.Deployment{ + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + }, + wantHosts: []orchestrator.Host{ + { + Name: "primary", + Port: 80, + }, + }, + }, + "error getting rds resources": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return nil, errors.New("some error") + } + }, + wantError: "get rds hosts: get tagged resources: some error", + }, + "no db instances found": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return nil, nil + } + }, + wantHosts: []orchestrator.Host{ + { + Name: "primary", + Port: 80, + }, + }, + }, + "invalid db arn": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return []*resourcegroups.Resource{ + { + ARN: "arn:invalid", + }, + }, nil + } + }, + wantError: `get rds hosts: invalid arn "arn:invalid": arn: not enough sections`, + }, + "error describing rds instances": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return []*resourcegroups.Resource{ + { + ARN: "arn:aws:rds:us-west-2:123456789:db:instanceID", + }, + }, nil + } + m.rds.DescribeDBInstancesPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBInstancesInput, f func(*rds.DescribeDBInstancesOutput, bool) bool, o ...request.Option) error { + return errors.New("some error") + } + }, + wantError: "get rds hosts: describe instances: some error", + }, + "gets rds instance": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return []*resourcegroups.Resource{ + { + ARN: "arn:aws:rds:us-west-2:123456789:db:instanceID", + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:subgrp:subgrpID", + }, + }, nil + } + m.rds.DescribeDBInstancesPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBInstancesInput, f func(*rds.DescribeDBInstancesOutput, bool) bool, o ...request.Option) error { + f(&rds.DescribeDBInstancesOutput{ + DBInstances: []*rds.DBInstance{ { - Status: aws.String("ACTIVE"), - ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ - Enabled: aws.Bool(true), - Services: []*sdkecs.ServiceConnectService{ - { - ClientAliases: []*sdkecs.ServiceConnectClientAlias{ - { - DnsName: aws.String("old"), - Port: aws.Int64(80), - }, - }, - }, - }, + Endpoint: &rds.Endpoint{ + Address: aws.String("db"), + Port: aws.Int64(3306), }, }, + }, + }, true) + return nil + } + }, + wantHosts: []orchestrator.Host{ + { + Name: "primary", + Port: 80, + }, + { + Name: "db", + Port: 3306, + }, + }, + }, + "error describing db cluster": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return []*resourcegroups.Resource{ + { + ARN: "arn:aws:rds:us-west-2:123456789:db:instanceID", + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:subgrp:subgrpID", + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:cluster:clusterID", + }, + }, nil + } + m.rds.DescribeDBInstancesPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBInstancesInput, f func(*rds.DescribeDBInstancesOutput, bool) bool, o ...request.Option) error { + f(&rds.DescribeDBInstancesOutput{ + DBInstances: []*rds.DBInstance{ { - Status: aws.String("PRIMARY"), - ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ - Enabled: aws.Bool(true), - Services: []*sdkecs.ServiceConnectService{ - { - ClientAliases: []*sdkecs.ServiceConnectClientAlias{ - { - DnsName: aws.String("primary"), - Port: aws.Int64(80), - }, - }, - }, - }, + Endpoint: &rds.Endpoint{ + Address: aws.String("db"), + Port: aws.Int64(3306), }, }, }, - }, - { - Deployments: []*sdkecs.Deployment{ + }, true) + return nil + } + m.rds.DescribeDBClustersPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBClustersInput, f func(*rds.DescribeDBClustersOutput, bool) bool, o ...request.Option) error { + return errors.New("some error") + } + }, + wantError: "get rds hosts: describe clusters: some error", + }, + "gets db cluster, skips other service resources": { + setupMocks: func(t *testing.T, m *testMocks) { + m.ecs.EXPECT().ServiceConnectServices(gomock.Any(), gomock.Any(), gomock.Any()).Return(ecsServices, nil) + m.rg.GetResourcesByTagsFn = func(s string, m map[string]string) ([]*resourcegroups.Resource, error) { + return []*resourcegroups.Resource{ + { + ARN: "arn:aws:rds:us-west-2:123456789:db:instanceID", + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:subgrp:subgrpID", + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:cluster:clusterID", + Tags: map[string]string{ + deploy.ServiceTagKey: "foo", + }, + }, + { + ARN: "arn:aws:rds:us-west-2:123456789:cluster:otherServiceCluster", + Tags: map[string]string{ + deploy.ServiceTagKey: "bar", + }, + }, + }, nil + } + m.rds.DescribeDBInstancesPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBInstancesInput, f func(*rds.DescribeDBInstancesOutput, bool) bool, o ...request.Option) error { + f(&rds.DescribeDBInstancesOutput{ + DBInstances: []*rds.DBInstance{ { - Status: aws.String("INACTIVE"), - ServiceConnectConfiguration: &sdkecs.ServiceConnectConfiguration{ - Enabled: aws.Bool(true), - Services: []*sdkecs.ServiceConnectService{ - { - ClientAliases: []*sdkecs.ServiceConnectClientAlias{ - { - DnsName: aws.String("inactive"), - Port: aws.Int64(80), - }, - }, - }, - }, + Endpoint: &rds.Endpoint{ + Address: aws.String("db"), + Port: aws.Int64(3306), }, }, }, - }, - }, nil) + }, true) + return nil + } + m.rds.DescribeDBClustersPagesWithContextFn = func(ctx context.Context, ddi *rds.DescribeDBClustersInput, f func(*rds.DescribeDBClustersOutput, bool) bool, o ...request.Option) error { + require.NotContains(t, ddi.Filters[0].Values, aws.String("arn:aws:rds:us-west-2:123456789:cluster:otherServiceCluster")) + + f(&rds.DescribeDBClustersOutput{ + DBClusters: []*rds.DBCluster{ + { + Endpoint: aws.String("cluster"), + Port: aws.Int64(5432), + ReaderEndpoint: aws.String("cluster-ro"), + CustomEndpoints: []*string{aws.String("cluster-custom")}, + }, + }, + }, true) + return nil + } }, wantHosts: []orchestrator.Host{ { Name: "primary", - Port: "80", + Port: 80, + }, + { + Name: "db", + Port: 3306, + }, + { + Name: "cluster", + Port: 5432, + }, + { + Name: "cluster-ro", + Port: 5432, + }, + { + Name: "cluster-custom", + Port: 5432, }, }, }, @@ -1512,11 +1756,16 @@ func TestRunLocal_HostDiscovery(t *testing.T) { defer ctrl.Finish() m := &testMocks{ ecs: mocks.NewMockecsClient(ctrl), + rg: &taggedResourceGetterDouble{}, + rds: &rdsDescriberDouble{}, } tc.setupMocks(t, m) h := &hostDiscoverer{ - ecs: m.ecs, + wkld: "foo", + ecs: m.ecs, + rg: m.rg, + rds: m.rds, } hosts, err := h.Hosts(context.Background()) diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml index ce6fab0bbae..9119767d703 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-basic-manifest.yml @@ -169,7 +169,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -258,6 +259,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -380,6 +387,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" VPC: Metadata: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-cloudfront-observability.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-cloudfront-observability.yml index 0e7877bae92..5ef51df2ff1 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-cloudfront-observability.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-cloudfront-observability.yml @@ -827,7 +827,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -916,6 +917,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -1038,6 +1045,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" AppRunnerVpcEndpointSecurityGroup: Metadata: 'aws:copilot:description': 'A security group for App Runner private services' diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml index 82509f754cf..dd3ae60f1f6 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-custom-security-group.yml @@ -700,7 +700,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -789,6 +790,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -911,6 +918,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" AppRunnerVpcEndpointSecurityGroup: Metadata: 'aws:copilot:description': 'A security group for App Runner private services' diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml index b4f03eb80d4..6bc21606956 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-default-access-log-config.yml @@ -226,7 +226,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -315,6 +316,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -442,6 +449,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" VPC: Metadata: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml index bca9a71214c..48a60b7b4bf 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-defaultvpc-flowlogs.yml @@ -174,7 +174,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -263,6 +264,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -385,6 +392,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" VPC: Metadata: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml index 450e3a0e67d..45c9cb4129a 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-sslpolicy-custom-empty-security-group.yml @@ -677,7 +677,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -766,6 +767,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -888,6 +895,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" AppRunnerVpcEndpointSecurityGroup: Metadata: 'aws:copilot:description': 'A security group for App Runner private services' diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml index b012be06d04..40e84fa76b1 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-importedvpc-flowlogs.yml @@ -181,7 +181,8 @@ Resources: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -270,6 +271,12 @@ Resources: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -392,6 +399,12 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" # Creates a service discovery namespace with the form provided in the parameter. # For new environments after 1.5.0, this is "env.app.local". For upgraded environments from diff --git a/internal/pkg/docker/orchestrator/orchestrator.go b/internal/pkg/docker/orchestrator/orchestrator.go index 155bc57cce6..31bff7f4dff 100644 --- a/internal/pkg/docker/orchestrator/orchestrator.go +++ b/internal/pkg/docker/orchestrator/orchestrator.go @@ -155,7 +155,7 @@ type RunTaskOption func(*runTaskAction) // Host represents a service reachable via the network. type Host struct { Name string - Port string + Port uint16 } // RunTaskWithProxy returns a RunTaskOption that sets up a proxy connection to hosts. @@ -258,7 +258,7 @@ func (o *Orchestrator) setupProxyConnections(ctx context.Context, pauseContainer err := o.docker.Exec(context.Background(), pauseContainer, io.Discard, "aws", "ssm", "start-session", "--target", a.ssmTarget, "--document-name", "AWS-StartPortForwardingSessionToRemoteHost", - "--parameters", fmt.Sprintf(`{"host":["%s"],"portNumber":["%s"],"localPortNumber":["%d"]}`, host.Name, host.Port, portForHost)) + "--parameters", fmt.Sprintf(`{"host":["%s"],"portNumber":["%d"],"localPortNumber":["%d"]}`, host.Name, host.Port, portForHost)) if err != nil { // report err as a runtime error from the pause container if o.curTaskID.Load() != orchestratorStoppedTaskID { @@ -276,7 +276,7 @@ func (o *Orchestrator) setupProxyConnections(ctx context.Context, pauseContainer "--destination", ip.String(), "--protocol", "tcp", "--match", "tcp", - "--dport", host.Port, + "--dport", strconv.Itoa(int(host.Port)), "--jump", "REDIRECT", "--to-ports", strconv.Itoa(int(port))) if err != nil { diff --git a/internal/pkg/docker/orchestrator/orchestrator_test.go b/internal/pkg/docker/orchestrator/orchestrator_test.go index 6f3a82f717e..3c06596a0e5 100644 --- a/internal/pkg/docker/orchestrator/orchestrator_test.go +++ b/internal/pkg/docker/orchestrator/orchestrator_test.go @@ -29,7 +29,7 @@ func TestOrchestrator(t *testing.T) { hosts := make([]Host, n) for i := 0; i < n; i++ { hosts[i].Name = strconv.Itoa(i) - hosts[i].Port = strconv.Itoa(i) + hosts[i].Port = uint16(i) } return hosts } @@ -204,7 +204,7 @@ func TestOrchestrator(t *testing.T) { o.RunTask(Task{}, RunTaskWithProxy("ecs:cluster_task_ctr", *ipNet, Host{ Name: "remote-foo", - Port: "80", + Port: 80, })) }, de }, @@ -257,7 +257,7 @@ func TestOrchestrator(t *testing.T) { o.RunTask(Task{}, RunTaskWithProxy("ecs:cluster_task_ctr", *ipNet, Host{ Name: "remote-foo", - Port: "80", + Port: 80, })) }, de }, @@ -286,7 +286,7 @@ func TestOrchestrator(t *testing.T) { o.RunTask(Task{}, RunTaskWithProxy("ecs:cluster_task_ctr", *ipNet, Host{ Name: "remote-foo", - Port: "80", + Port: 80, })) }, de }, @@ -315,7 +315,7 @@ func TestOrchestrator(t *testing.T) { o.RunTask(Task{}, RunTaskWithProxy("ecs:cluster_task_ctr", *ipNet, Host{ Name: "remote-foo", - Port: "80", + Port: 80, })) }, de }, @@ -353,7 +353,7 @@ func TestOrchestrator(t *testing.T) { }, }, RunTaskWithProxy("ecs:cluster_task_ctr", *ipNet, Host{ Name: "remote-foo", - Port: "80", + Port: 80, })) <-waitUntilRun diff --git a/internal/pkg/template/templates/environment/partials/environment-manager-role.yml b/internal/pkg/template/templates/environment/partials/environment-manager-role.yml index 006f4c66e53..15c3053ee64 100644 --- a/internal/pkg/template/templates/environment/partials/environment-manager-role.yml +++ b/internal/pkg/template/templates/environment/partials/environment-manager-role.yml @@ -88,7 +88,8 @@ EnvironmentManagerRole: - Sid: ExecuteCommand Effect: Allow Action: [ - "ecs:ExecuteCommand" + "ecs:ExecuteCommand", + "ssm:StartSession" ] Resource: "*" Condition: @@ -177,6 +178,12 @@ EnvironmentManagerRole: ] Resource: - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/copilot/${AppName}/${EnvironmentName}/secrets/*' + - Sid: SSMSession + Effect: Allow + Action: + - ssm:StartSession + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-StartPortForwardingSessionToRemoteHost" - Sid: ELBv2 Effect: Allow Action: [ @@ -305,4 +312,10 @@ EnvironmentManagerRole: - 'cloudformation:DescribeStacks' - 'cloudformation:DeleteStack' Resource: - - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' \ No newline at end of file + - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' + - Sid: RDS + Effect: Allow + Action: + - 'rds:DescribeDBInstances' + - 'rds:DescribeDBClusters' + Resource: "*" \ No newline at end of file