diff --git a/go.mod b/go.mod index 2faaa66bfce..1f6e176cacb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.18 require ( github.com/AlecAivazis/survey/v2 v2.3.2 - github.com/aws/aws-sdk-go v1.44.126 + github.com/aws/aws-sdk-go v1.44.127 github.com/briandowns/spinner v1.19.0 github.com/dustin/go-humanize v1.0.0 github.com/fatih/color v1.13.0 diff --git a/go.sum b/go.sum index 9f6397ee2a8..fc125142e20 100644 --- a/go.sum +++ b/go.sum @@ -180,8 +180,8 @@ github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.25.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.27.1/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.31.6/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= -github.com/aws/aws-sdk-go v1.44.126 h1:7HQJw2DNiwpxqMe2H7odGNT2rhO4SRrUe5/8dYXl0Jk= -github.com/aws/aws-sdk-go v1.44.126/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go v1.44.127 h1:IoO2VfuIQg1aMXnl8l6OpNUKT4Qq5CnJMOyIWoTYXj0= +github.com/aws/aws-sdk-go v1.44.127/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= diff --git a/internal/pkg/aws/apprunner/apprunner.go b/internal/pkg/aws/apprunner/apprunner.go index 26726152796..d8f1d1f647e 100644 --- a/internal/pkg/aws/apprunner/apprunner.go +++ b/internal/pkg/aws/apprunner/apprunner.go @@ -42,6 +42,7 @@ type api interface { ResumeService(input *apprunner.ResumeServiceInput) (*apprunner.ResumeServiceOutput, error) StartDeployment(input *apprunner.StartDeploymentInput) (*apprunner.StartDeploymentOutput, error) DescribeObservabilityConfiguration(input *apprunner.DescribeObservabilityConfigurationInput) (*apprunner.DescribeObservabilityConfigurationOutput, error) + DescribeVpcIngressConnection(input *apprunner.DescribeVpcIngressConnectionInput) (*apprunner.DescribeVpcIngressConnectionOutput, error) } // AppRunner wraps an AWS AppRunner client. @@ -213,6 +214,18 @@ func (a *AppRunner) WaitForOperation(operationId, svcARN string) error { } } +// PrivateURL returns the url associated with a VPC Ingress Connection. +func (a *AppRunner) PrivateURL(vicARN string) (string, error) { + resp, err := a.client.DescribeVpcIngressConnection(&apprunner.DescribeVpcIngressConnectionInput{ + VpcIngressConnectionArn: aws.String(vicARN), + }) + if err != nil { + return "", fmt.Errorf("describe vpc ingress connection %q: %w", vicARN, err) + } + + return aws.StringValue(resp.VpcIngressConnection.DomainName), nil +} + // ParseServiceName returns the service name. // For example: arn:aws:apprunner:us-west-2:1234567890:service/my-service/fc1098ac269245959ba78fd58bdd4bf // will return my-service diff --git a/internal/pkg/aws/apprunner/apprunner_test.go b/internal/pkg/aws/apprunner/apprunner_test.go index 2b527b866c7..2b4bac5a34e 100644 --- a/internal/pkg/aws/apprunner/apprunner_test.go +++ b/internal/pkg/aws/apprunner/apprunner_test.go @@ -395,6 +395,56 @@ func TestAppRunner_DescribeOperation(t *testing.T) { } } +func TestAppRunner_PrivateURL(t *testing.T) { + const mockARN = "mockVicArn" + tests := map[string]struct { + mockAppRunnerClient func(m *mocks.Mockapi) + expectedErr string + expectedURL string + }{ + "error if error from sdk": { + mockAppRunnerClient: func(m *mocks.Mockapi) { + m.EXPECT().DescribeVpcIngressConnection(&apprunner.DescribeVpcIngressConnectionInput{ + VpcIngressConnectionArn: aws.String(mockARN), + }).Return(nil, errors.New("some error")) + }, + expectedErr: `describe vpc ingress connection "mockVicArn": some error`, + }, + "success": { + mockAppRunnerClient: func(m *mocks.Mockapi) { + m.EXPECT().DescribeVpcIngressConnection(&apprunner.DescribeVpcIngressConnectionInput{ + VpcIngressConnectionArn: aws.String(mockARN), + }).Return(&apprunner.DescribeVpcIngressConnectionOutput{ + VpcIngressConnection: &apprunner.VpcIngressConnection{ + DomainName: aws.String("example.com"), + }, + }, nil) + }, + expectedURL: "example.com", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockAppRunnerClient := mocks.NewMockapi(ctrl) + tc.mockAppRunnerClient(mockAppRunnerClient) + + service := AppRunner{ + client: mockAppRunnerClient, + } + + url, err := service.PrivateURL(mockARN) + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } + require.Equal(t, tc.expectedURL, url) + }) + } +} + func TestAppRunner_PauseService(t *testing.T) { const ( mockOperationId = "mock-operation" diff --git a/internal/pkg/aws/apprunner/mocks/mock_apprunner.go b/internal/pkg/aws/apprunner/mocks/mock_apprunner.go index e067be5b3cc..e00754c904d 100644 --- a/internal/pkg/aws/apprunner/mocks/mock_apprunner.go +++ b/internal/pkg/aws/apprunner/mocks/mock_apprunner.go @@ -64,6 +64,21 @@ func (mr *MockapiMockRecorder) DescribeService(input interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeService", reflect.TypeOf((*Mockapi)(nil).DescribeService), input) } +// DescribeVpcIngressConnection mocks base method. +func (m *Mockapi) DescribeVpcIngressConnection(input *apprunner.DescribeVpcIngressConnectionInput) (*apprunner.DescribeVpcIngressConnectionOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DescribeVpcIngressConnection", input) + ret0, _ := ret[0].(*apprunner.DescribeVpcIngressConnectionOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeVpcIngressConnection indicates an expected call of DescribeVpcIngressConnection. +func (mr *MockapiMockRecorder) DescribeVpcIngressConnection(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeVpcIngressConnection", reflect.TypeOf((*Mockapi)(nil).DescribeVpcIngressConnection), input) +} + // ListOperations mocks base method. func (m *Mockapi) ListOperations(input *apprunner.ListOperationsInput) (*apprunner.ListOperationsOutput, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/cli/flag.go b/internal/pkg/cli/flag.go index 4121a8bfa1f..8ff86c9f821 100644 --- a/internal/pkg/cli/flag.go +++ b/internal/pkg/cli/flag.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/aws/copilot-cli/internal/pkg/manifest" + "github.com/dustin/go-humanize/english" ) // Long flag names. @@ -64,6 +65,8 @@ const ( noSubscriptionFlag = "no-subscribe" subscribeTopicsFlag = "subscribe-topics" + ingressTypeFlag = "ingress-type" + storageTypeFlag = "storage-type" storagePartitionKeyFlag = "partition-key" storageSortKeyFlag = "sort-key" @@ -200,6 +203,9 @@ Mutually exclusive with the -%s ,--%s and --%s flags.`, nameFlagShort, nameFlag, repoURLFlagDescription = fmt.Sprintf(`The repository URL to trigger your pipeline. Supported providers are: %s.`, strings.Join(manifest.PipelineProviders, ", ")) + + ingressTypeFlagDescription = fmt.Sprintf(`Required for a Request-Driven Web Service. Allowed source of traffic to your service. +Must be one of %s`, english.OxfordWordSeries(rdwsIngressOptions, "or")) ) const ( diff --git a/internal/pkg/cli/svc_init.go b/internal/pkg/cli/svc_init.go index 162bf0aedfb..3e54f7fb9b4 100644 --- a/internal/pkg/cli/svc_init.go +++ b/internal/pkg/cli/svc_init.go @@ -8,9 +8,11 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/aws/aws-sdk-go/service/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/identity" + "github.com/dustin/go-humanize/english" "github.com/aws/aws-sdk-go/aws" "github.com/aws/copilot-cli/internal/pkg/docker/dockerfile" @@ -48,7 +50,7 @@ const ( var ( fmtSvcInitSvcTypePrompt = "Which %s best represents your service's architecture?" - svcInitSvcTypeHelpPrompt = fmt.Sprintf(`A %s is an internet-facing HTTP server managed by AWS App Runner that scales based on incoming requests. + svcInitSvcTypeHelpPrompt = fmt.Sprintf(`A %s is an internet-facing or private HTTP server managed by AWS App Runner that scales based on incoming requests. To learn more see: https://git.io/JEEfb A %s is an internet-facing HTTP server managed by Amazon ECS on AWS Fargate behind a load balancer. @@ -82,9 +84,23 @@ You should set this to the port which your Dockerfile uses to communicate with t svcInitPublisherHelpPrompt = `A publisher is an existing SNS Topic to which a service publishes messages. These messages can be consumed by the Worker Service.` + svcInitIngressTypePrompt = "Would you like to accept traffic from your environment or the internet?" + svcInitIngressTypeHelpPrompt = `"Environment" will configure your service as private. +"Internet" will configure your service as public.` + wkldInitImagePrompt = fmt.Sprintf("What's the %s ([registry/]repository[:tag|@digest]) of the image to use?", color.Emphasize("location")) ) +const ( + ingressTypeEnvironment = "Environment" + ingressTypeInternet = "Internet" +) + +var rdwsIngressOptions = []string{ + ingressTypeEnvironment, + ingressTypeInternet, +} + var serviceTypeHints = map[string]string{ manifest.RequestDrivenWebServiceType: "App Runner", manifest.LoadBalancedWebServiceType: "Internet to ECS on Fargate", @@ -100,6 +116,7 @@ type initWkldVars struct { image string subscriptions []string noSubscribe bool + ingressType string } type initSvcVars struct { @@ -215,6 +232,9 @@ func (o *initSvcOpts) Validate() error { if err := validateSubscribe(o.noSubscribe, o.subscriptions); err != nil { return err } + if err := o.validateIngressType(); err != nil { + return err + } return nil } @@ -253,6 +273,9 @@ func (o *initSvcOpts) Ask() error { if err := o.validateSvc(); err != nil { return err } + if err := o.askIngressType(); err != nil { + return err + } shouldSkipAsking, err := o.shouldSkipAsking() if err != nil { return err @@ -313,6 +336,7 @@ func (o *initSvcOpts) Execute() error { }, Port: o.port, HealthCheck: hc, + Private: strings.EqualFold(o.ingressType, ingressTypeEnvironment), }) if err != nil { return err @@ -389,6 +413,34 @@ func (o *initSvcOpts) askSvcName() error { return nil } +func (o *initSvcOpts) askIngressType() error { + if o.wkldType != manifest.RequestDrivenWebServiceType || o.ingressType != "" { + return nil + } + + var opts []prompt.Option + for _, typ := range rdwsIngressOptions { + opts = append(opts, prompt.Option{Value: typ}) + } + + t, err := o.prompt.SelectOption(svcInitIngressTypePrompt, svcInitIngressTypeHelpPrompt, opts, prompt.WithFinalMessage("Reachable from:")) + if err != nil { + return fmt.Errorf("select ingress type: %w", err) + } + o.ingressType = t + return nil +} + +func (o *initSvcOpts) validateIngressType() error { + if o.wkldType != manifest.RequestDrivenWebServiceType { + return nil + } + if strings.EqualFold(o.ingressType, "internet") || strings.EqualFold(o.ingressType, "environment") { + return nil + } + return fmt.Errorf("invalid ingress type %q: must be one of %s", o.ingressType, english.OxfordWordSeries(rdwsIngressOptions, "or")) +} + func (o *initSvcOpts) askImage() error { if o.image != "" { return nil @@ -712,6 +764,7 @@ This command is also run as part of "copilot init".`, cmd.Flags().Uint16Var(&vars.port, svcPortFlag, 0, svcPortFlagDescription) cmd.Flags().StringArrayVar(&vars.subscriptions, subscribeTopicsFlag, []string{}, subscribeTopicsFlagDescription) cmd.Flags().BoolVar(&vars.noSubscribe, noSubscriptionFlag, false, noSubscriptionFlagDescription) + cmd.Flags().StringVar(&vars.ingressType, ingressTypeFlag, "", ingressTypeFlagDescription) return cmd } diff --git a/internal/pkg/cli/svc_init_test.go b/internal/pkg/cli/svc_init_test.go index 2fd9912ed36..6267f75cbbf 100644 --- a/internal/pkg/cli/svc_init_test.go +++ b/internal/pkg/cli/svc_init_test.go @@ -45,6 +45,7 @@ func TestSvcInitOpts_Validate(t *testing.T) { inSvcPort uint16 inSubscribeTags []string inNoSubscribe bool + inIngressType string setupMocks func(mocks initSvcMocks) mockFileSystem func(mockFS afero.Fs) @@ -103,6 +104,21 @@ func TestSvcInitOpts_Validate(t *testing.T) { }, wantedErr: errors.New("validate subscribe configuration: cannot specify both --no-subscribe and --subscribe-topics"), }, + "rdws invalid ingress type error": { + inSvcName: "frontend", + inSvcType: "Request-Driven Web Service", + inDockerfilePath: "./hello/Dockerfile", + inIngressType: "invalid", + + setupMocks: func(m initSvcMocks) { + m.mockStore.EXPECT().GetApplication("phonetool").Return(&config.Application{}, nil) + }, + mockFileSystem: func(mockFS afero.Fs) { + mockFS.MkdirAll("hello", 0755) + afero.WriteFile(mockFS, "hello/Dockerfile", []byte("FROM nginx"), 0644) + }, + wantedErr: errors.New(`invalid ingress type "invalid": must be one of Environment or Internet`), + }, "valid flags": { inSvcName: "frontend", inSvcType: "Load Balanced Web Service", @@ -115,7 +131,20 @@ func TestSvcInitOpts_Validate(t *testing.T) { mockFS.MkdirAll("hello", 0755) afero.WriteFile(mockFS, "hello/Dockerfile", []byte("FROM nginx"), 0644) }, - wantedErr: nil, + }, + "valid rdws flags": { + inSvcName: "frontend", + inSvcType: "Request-Driven Web Service", + inDockerfilePath: "./hello/Dockerfile", + inIngressType: "Internet", + + setupMocks: func(m initSvcMocks) { + m.mockStore.EXPECT().GetApplication("phonetool").Return(&config.Application{}, nil) + }, + mockFileSystem: func(mockFS afero.Fs) { + mockFS.MkdirAll("hello", 0755) + afero.WriteFile(mockFS, "hello/Dockerfile", []byte("FROM nginx"), 0644) + }, }, } @@ -141,6 +170,7 @@ func TestSvcInitOpts_Validate(t *testing.T) { appName: tc.inAppName, subscriptions: tc.inSubscribeTags, noSubscribe: tc.inNoSubscribe, + ingressType: tc.inIngressType, }, port: tc.inSvcPort, }, @@ -186,6 +216,7 @@ func TestSvcInitOpts_Ask(t *testing.T) { inSvcPort uint16 inSubscribeTags []string inNoSubscribe bool + inIngressType string setupMocks func(mocks initSvcMocks) @@ -354,6 +385,56 @@ type: Request-Driven Web Service`), nil) }, wantedErr: fmt.Errorf("service name iamoverfortycharacterlongandaninvalidrdwsname is invalid: value must not exceed 40 characters"), }, + "rdws prompt for ingress type": { + inSvcType: appRunnerSvcType, + inSvcName: wantedSvcName, + inSvcPort: wantedSvcPort, + inDockerfilePath: wantedDockerfilePath, + + setupMocks: func(m initSvcMocks) { + m.mockStore.EXPECT().GetService(mockAppName, wantedSvcName).Return(nil, &config.ErrNoSuchService{}) + m.mockMftReader.EXPECT().ReadWorkloadManifest(wantedSvcName).Return(nil, &workspace.ErrFileNotExists{FileName: wantedSvcName}) + m.mockPrompt.EXPECT().SelectOption(gomock.Eq(svcInitIngressTypePrompt), gomock.Any(), gomock.Eq([]prompt.Option{ + { + Value: "Environment", + }, + { + Value: "Internet", + }, + }), gomock.Any()).Return("Environment", nil) + }, + }, + "rdws prompt for ingress type error": { + inSvcType: appRunnerSvcType, + inSvcName: wantedSvcName, + inSvcPort: wantedSvcPort, + inDockerfilePath: wantedDockerfilePath, + + setupMocks: func(m initSvcMocks) { + m.mockStore.EXPECT().GetService(mockAppName, wantedSvcName).Return(nil, &config.ErrNoSuchService{}) + m.mockPrompt.EXPECT().SelectOption(gomock.Eq(svcInitIngressTypePrompt), gomock.Any(), gomock.Eq([]prompt.Option{ + { + Value: "Environment", + }, + { + Value: "Internet", + }, + }), gomock.Any()).Return("", errors.New("some error")) + }, + wantedErr: errors.New("select ingress type: some error"), + }, + "rdws skip ingress type prompt with flag": { + inSvcType: appRunnerSvcType, + inSvcName: wantedSvcName, + inSvcPort: wantedSvcPort, + inDockerfilePath: wantedDockerfilePath, + inIngressType: ingressTypeInternet, + + setupMocks: func(m initSvcMocks) { + m.mockStore.EXPECT().GetService(mockAppName, wantedSvcName).Return(nil, &config.ErrNoSuchService{}) + m.mockMftReader.EXPECT().ReadWorkloadManifest(wantedSvcName).Return(nil, &workspace.ErrFileNotExists{FileName: wantedSvcName}) + }, + }, "skip selecting Dockerfile if image flag is set": { inSvcType: wantedSvcType, inSvcName: wantedSvcName, @@ -647,6 +728,7 @@ type: Request-Driven Web Service`), nil) dockerfilePath: tc.inDockerfilePath, noSubscribe: tc.inNoSubscribe, subscriptions: tc.inSubscribeTags, + ingressType: tc.inIngressType, appName: mockAppName, }, port: tc.inSvcPort, diff --git a/internal/pkg/deploy/cloudformation/stack/env.go b/internal/pkg/deploy/cloudformation/stack/env.go index 2b1e090b543..3b68f4ccf64 100644 --- a/internal/pkg/deploy/cloudformation/stack/env.go +++ b/internal/pkg/deploy/cloudformation/stack/env.go @@ -42,6 +42,7 @@ const ( envParamInternalALBWorkloadsKey = "InternalALBWorkloads" envParamEFSWorkloadsKey = "EFSWorkloads" envParamNATWorkloadsKey = "NATWorkloads" + envParamAppRunnerPrivateWorkloadsKey = "AppRunnerPrivateWorkloads" envParamCreateHTTPSListenerKey = "CreateHTTPSListener" envParamCreateInternalHTTPSListenerKey = "CreateInternalHTTPSListener" EnvParamServiceDiscoveryEndpoint = "ServiceDiscoveryEndpoint" @@ -194,6 +195,10 @@ func (e *EnvStackConfig) Parameters() ([]*cloudformation.Parameter, error) { ParameterKey: aws.String(envParamNATWorkloadsKey), ParameterValue: aws.String(""), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, } if e.prevParams == nil { return currParams, nil diff --git a/internal/pkg/deploy/cloudformation/stack/env_integration_test.go b/internal/pkg/deploy/cloudformation/stack/env_integration_test.go index 824ec883b81..b1144179e43 100644 --- a/internal/pkg/deploy/cloudformation/stack/env_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/env_integration_test.go @@ -254,7 +254,6 @@ network: }(), wantedFileName: "template-with-defaultvpc-flowlogs.yml", }, - "generate template with imported vpc and flowlogs is on": { input: func() *deploy.CreateEnvironmentInput { rawMft := `name: test diff --git a/internal/pkg/deploy/cloudformation/stack/env_test.go b/internal/pkg/deploy/cloudformation/stack/env_test.go index 97c3680d701..526ccf54e03 100644 --- a/internal/pkg/deploy/cloudformation/stack/env_test.go +++ b/internal/pkg/deploy/cloudformation/stack/env_test.go @@ -163,6 +163,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, "with DNS": { @@ -220,6 +224,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, "with private DNS only": { @@ -277,6 +285,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("true"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, "should use default value for new EnvControllerParameters": { @@ -330,6 +342,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String("rdws-backend"), + }, }, want: []*cloudformation.Parameter{ @@ -385,6 +401,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String("rdws-backend"), + }, }, }, "should retain the values from EnvControllerParameters": { @@ -398,6 +418,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamNATWorkloadsKey), ParameterValue: aws.String("backend"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String("rdws-backend"), + }, { ParameterKey: aws.String(EnvParamAliasesKey), ParameterValue: aws.String(""), @@ -497,6 +521,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String("rdws-backend"), + }, }, }, "should not include old parameters that are deleted": { @@ -557,6 +585,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, want: []*cloudformation.Parameter{ @@ -612,6 +644,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, "should reuse old service discovery endpoint value": { @@ -669,6 +705,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, want: []*cloudformation.Parameter{ @@ -724,6 +764,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, "should use app.local endpoint service discovery endpoint if it is a new parameter": { @@ -753,6 +797,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamAppDNSDelegationRoleKey), ParameterValue: aws.String(""), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, want: []*cloudformation.Parameter{ @@ -808,6 +856,10 @@ func TestEnv_Parameters(t *testing.T) { ParameterKey: aws.String(envParamCreateInternalHTTPSListenerKey), ParameterValue: aws.String("false"), }, + { + ParameterKey: aws.String(envParamAppRunnerPrivateWorkloadsKey), + ParameterValue: aws.String(""), + }, }, }, } diff --git a/internal/pkg/deploy/cloudformation/stack/rd_web_svc.go b/internal/pkg/deploy/cloudformation/stack/rd_web_svc.go index 6099355e2e9..c7c144de853 100644 --- a/internal/pkg/deploy/cloudformation/stack/rd_web_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/rd_web_svc.go @@ -140,7 +140,9 @@ func (s *RequestDrivenWebService) Template() (string, error) { Observability: template.ObservabilityOpts{ Tracing: strings.ToUpper(aws.StringValue(s.manifest.Observability.Tracing)), }, - PermissionsBoundary: s.permBound, + PermissionsBoundary: s.permBound, + Private: !s.manifest.Private.IsZero(), + AppRunnerVPCEndpoint: s.manifest.Private.Advanced.Endpoint, }) if err != nil { return "", err 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 c906eb6fd6a..439065144e0 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 @@ -18,6 +18,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -51,6 +53,8 @@ Conditions: !Not [!Equals [ !Ref EFSWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -342,7 +346,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -993,6 +998,41 @@ Resources: DomainName: !Ref AppDNSName PublicAccessDNS: !GetAtt PublicLoadBalancer.DNSName PublicAccessHostedZone: !GetAtt PublicLoadBalancer.CanonicalHostedZoneID + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1124,7 +1164,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-SubDomain EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1138,3 +1178,9 @@ Outputs: LastForceDeployID: Value: "" Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId 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 3ad2b44dd8b..f1d80e3d8b6 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 @@ -40,6 +40,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -73,6 +75,8 @@ Conditions: !Not [!Equals [ !Ref InternalALBWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -873,7 +877,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -905,7 +910,41 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' - + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1025,7 +1064,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-CFNExecutionRoleARN EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1038,4 +1077,10 @@ Outputs: Value: true LastForceDeployID: Value: "" - Description: Optionally force the template to update when no immediate resource change is present. \ No newline at end of file + Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId 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 5475b10cedb..49863decaa6 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 @@ -21,6 +21,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -54,6 +56,8 @@ Conditions: !Not [!Equals [ !Ref EFSWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -399,7 +403,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -1089,6 +1094,41 @@ Resources: DomainName: !Ref AppDNSName PublicAccessDNS: !GetAtt PublicLoadBalancer.DNSName PublicAccessHostedZone: !GetAtt PublicLoadBalancer.CanonicalHostedZoneID + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1220,7 +1260,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-SubDomain EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1234,3 +1274,10 @@ Outputs: LastForceDeployID: Value: "" Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId + 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 f61bc678f74..3ed21b7a7a4 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 @@ -22,6 +22,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -55,6 +57,8 @@ Conditions: !Not [!Equals [ !Ref EFSWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -346,7 +350,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -1041,6 +1046,41 @@ Resources: - logs:DescribeLogGroups - logs:DescribeLogStreams Resource: "*" + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1172,7 +1212,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-SubDomain EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1186,3 +1226,10 @@ Outputs: LastForceDeployID: Value: "" Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId + diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-observability.yml b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-observability.yml index 2e3df8c5bd3..8d6aee9a368 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-observability.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/environments/template-with-imported-certs-observability.yml @@ -40,6 +40,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -73,6 +75,8 @@ Conditions: !Not [!Equals [ !Ref InternalALBWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -976,7 +980,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -1008,7 +1013,41 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' - + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1128,7 +1167,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-CFNExecutionRoleARN EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1148,3 +1187,10 @@ Outputs: LastForceDeployID: Value: "" Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId + 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 8ae2be51091..fa5bf11e015 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 @@ -30,6 +30,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -63,6 +65,8 @@ Conditions: !Not [!Equals [ !Ref InternalALBWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -850,7 +854,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -882,7 +887,41 @@ Resources: - 'cloudformation:DeleteStack' Resource: - !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*' - + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: !Ref VPC + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: !Ref VPC @@ -1002,7 +1041,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-CFNExecutionRoleARN EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1015,4 +1054,10 @@ Outputs: Value: true LastForceDeployID: Value: "" - Description: Optionally force the template to update when no immediate resource change is present. \ No newline at end of file + Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId 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 2f239575d22..56f75d92f40 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 @@ -30,6 +30,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -63,6 +65,8 @@ Conditions: !Not [!Equals [ !Ref EFSWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -354,7 +358,8 @@ Resources: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags @@ -871,6 +876,41 @@ Resources: - logs:DescribeLogGroups - logs:DescribeLogStreams Resource: "*" + AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: demo-test-AppRunnerVpcEndpointSecurityGroup + VpcId: vpc-12345 + Tags: + - Key: Name + Value: copilot-demo-test-app-runner-vpc-endpoint + + AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + + AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 Outputs: VpcId: Value: vpc-12345 @@ -989,7 +1029,7 @@ Outputs: Export: Name: !Sub ${AWS::StackName}-SubDomain EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -1003,3 +1043,9 @@ Outputs: LastForceDeployID: Value: "" Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-prod.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-prod.stack.yml index 336c4c7b2bb..b7fe3d3e474 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-prod.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-prod.stack.yml @@ -86,7 +86,8 @@ Resources: Statement: - Effect: Allow Principal: - Service: tasks.apprunner.amazonaws.com + Service: + - tasks.apprunner.amazonaws.com Action: 'sts:AssumeRole' Policies: - PolicyName: 'DenyIAMExceptTaggedRoles' @@ -258,7 +259,8 @@ Resources: - mockSubnetID1 - mockSubnetID2 SecurityGroups: - - !Ref ServiceSecurityGroup + - !Ref ServiceSecurityGroup + - Fn::ImportValue: !Sub "${AppName}-${EnvName}-EnvironmentSecurityGroup" Tags: - Key: copilot-application Value: !Ref AppName diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-test.stack.yml index fe2d9c0720e..b8d3a485bab 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/rdws-test.stack.yml @@ -86,7 +86,8 @@ Resources: Statement: - Effect: Allow Principal: - Service: tasks.apprunner.amazonaws.com + Service: + - tasks.apprunner.amazonaws.com Action: 'sts:AssumeRole' Policies: - PolicyName: 'DenyIAMExceptTaggedRoles' @@ -331,7 +332,8 @@ Resources: - Fn::ImportValue: !Sub '${AppName}-${EnvName}-PrivateSubnets' SecurityGroups: - - !Ref ServiceSecurityGroup + - !Ref ServiceSecurityGroup + - Fn::ImportValue: !Sub "${AppName}-${EnvName}-EnvironmentSecurityGroup" Tags: - Key: copilot-application Value: !Ref AppName diff --git a/internal/pkg/deploy/env.go b/internal/pkg/deploy/env.go index 254b7834f68..b795163d10b 100644 --- a/internal/pkg/deploy/env.go +++ b/internal/pkg/deploy/env.go @@ -14,7 +14,7 @@ const ( // LegacyEnvTemplateVersion is the version associated with the environment template before we started versioning. LegacyEnvTemplateVersion = "v0.0.0" // LatestEnvTemplateVersion is the latest version number available for environment templates. - LatestEnvTemplateVersion = "v1.12.3" + LatestEnvTemplateVersion = "v1.13.0" EnvTemplateVersionBootstrap = "bootstrap" ) diff --git a/internal/pkg/describe/errors.go b/internal/pkg/describe/errors.go index 8b714127fcf..a1a17e56e11 100644 --- a/internal/pkg/describe/errors.go +++ b/internal/pkg/describe/errors.go @@ -4,6 +4,7 @@ package describe import ( + "errors" "fmt" "github.com/aws/copilot-cli/internal/pkg/template" @@ -31,3 +32,5 @@ func (err *errLBWebSvcsOnCFWithoutAlias) Error() string { return fmt.Sprintf("%s %s must have %q specified when CloudFront is enabled", english.PluralWord(len(err.services), "service", "services"), english.WordSeries(template.QuoteSliceFunc(err.services), "and"), err.aliasField) } + +var errVPCIngressConnectionNotFound = errors.New("no vpc ingress connection found") diff --git a/internal/pkg/describe/mocks/mock_service.go b/internal/pkg/describe/mocks/mock_service.go index 15b6d1e1bdc..d0e6e6e6162 100644 --- a/internal/pkg/describe/mocks/mock_service.go +++ b/internal/pkg/describe/mocks/mock_service.go @@ -242,18 +242,33 @@ func (m *MockapprunnerClient) EXPECT() *MockapprunnerClientMockRecorder { } // DescribeService mocks base method. -func (m *MockapprunnerClient) DescribeService(svcArn string) (*apprunner.Service, error) { +func (m *MockapprunnerClient) DescribeService(svcARN string) (*apprunner.Service, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DescribeService", svcArn) + ret := m.ctrl.Call(m, "DescribeService", svcARN) ret0, _ := ret[0].(*apprunner.Service) ret1, _ := ret[1].(error) return ret0, ret1 } // DescribeService indicates an expected call of DescribeService. -func (mr *MockapprunnerClientMockRecorder) DescribeService(svcArn interface{}) *gomock.Call { +func (mr *MockapprunnerClientMockRecorder) DescribeService(svcARN interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeService", reflect.TypeOf((*MockapprunnerClient)(nil).DescribeService), svcArn) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeService", reflect.TypeOf((*MockapprunnerClient)(nil).DescribeService), svcARN) +} + +// PrivateURL mocks base method. +func (m *MockapprunnerClient) PrivateURL(vicARN string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PrivateURL", vicARN) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PrivateURL indicates an expected call of PrivateURL. +func (mr *MockapprunnerClientMockRecorder) PrivateURL(vicARN interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrivateURL", reflect.TypeOf((*MockapprunnerClient)(nil).PrivateURL), vicARN) } // MockworkloadStackDescriber is a mock of workloadStackDescriber interface. @@ -490,6 +505,21 @@ func (m *MockapprunnerDescriber) EXPECT() *MockapprunnerDescriberMockRecorder { return m.recorder } +// IsPrivate mocks base method. +func (m *MockapprunnerDescriber) IsPrivate() (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsPrivate") + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsPrivate indicates an expected call of IsPrivate. +func (mr *MockapprunnerDescriberMockRecorder) IsPrivate() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsPrivate", reflect.TypeOf((*MockapprunnerDescriber)(nil).IsPrivate)) +} + // Manifest mocks base method. func (m *MockapprunnerDescriber) Manifest() ([]byte, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/describe/rd_web_service.go b/internal/pkg/describe/rd_web_service.go index 85b76c60819..6b19796d68c 100644 --- a/internal/pkg/describe/rd_web_service.go +++ b/internal/pkg/describe/rd_web_service.go @@ -75,7 +75,7 @@ func (d *RDWebServiceDescriber) Describe() (HumanJSONStringer, error) { } var observabilities []observabilityInEnv - var routes []*WebServiceRoute + var routes []*RDWSRoute var configs []*ServiceConfig var envVars envVars resources := make(map[string][]*stack.Resource) @@ -88,10 +88,22 @@ func (d *RDWebServiceDescriber) Describe() (HumanJSONStringer, error) { if err != nil { return nil, fmt.Errorf("retrieve service configuration: %w", err) } - webServiceURI := formatAppRunnerUrl(service.ServiceURL) - routes = append(routes, &WebServiceRoute{ + url, err := describer.ServiceURL() + if err != nil { + return nil, fmt.Errorf("retrieve service url: %w", err) + } + private, err := describer.IsPrivate() + if err != nil { + return nil, fmt.Errorf("check if service is private: %w", err) + } + ingress := rdwsIngressInternet + if private { + ingress = rdwsIngressEnvironment + } + routes = append(routes, &RDWSRoute{ Environment: env, - URL: webServiceURI, + URL: url, + Ingress: ingress, }) configs = append(configs, &ServiceConfig{ Environment: env, @@ -196,13 +208,27 @@ func (t *tracing) isEmpty() bool { return t == nil || t.Vendor == "" } +type rdwsIngress string + +const ( + rdwsIngressEnvironment rdwsIngress = "environment" + rdwsIngressInternet rdwsIngress = "internet" +) + +// RDWSRoute contains serialized route parameters for a Request-Driven Web Service. +type RDWSRoute struct { + Environment string `json:"environment"` + URL string `json:"url"` + Ingress rdwsIngress `json:"ingress"` +} + // rdWebSvcDesc contains serialized parameters for a web service. type rdWebSvcDesc struct { Service string `json:"service"` Type string `json:"type"` App string `json:"application"` AppRunnerConfigurations appRunnerConfigurations `json:"configurations"` - Routes []*WebServiceRoute `json:"routes"` + Routes []*RDWSRoute `json:"routes"` Variables envVars `json:"variables"` Resources deployedSvcResources `json:"resources,omitempty"` Observability observabilityPerEnv `json:"observability,omitempty"` @@ -238,11 +264,11 @@ func (w *rdWebSvcDesc) HumanString() string { } fmt.Fprint(writer, color.Bold.Sprint("\nRoutes\n\n")) writer.Flush() - headers := []string{"Environment", "URL"} + headers := []string{"Environment", "Ingress", "URL"} fmt.Fprintf(writer, " %s\n", strings.Join(headers, "\t")) fmt.Fprintf(writer, " %s\n", strings.Join(underline(headers), "\t")) for _, route := range w.Routes { - fmt.Fprintf(writer, " %s\t%s\n", route.Environment, route.URL) + fmt.Fprintf(writer, " %s\t%s\t%s\n", route.Environment, route.Ingress, route.URL) } fmt.Fprint(writer, color.Bold.Sprint("\nVariables\n\n")) diff --git a/internal/pkg/describe/rd_web_service_test.go b/internal/pkg/describe/rd_web_service_test.go index 5f1f5ada056..5b6b3cc161a 100644 --- a/internal/pkg/describe/rd_web_service_test.go +++ b/internal/pkg/describe/rd_web_service_test.go @@ -31,10 +31,10 @@ Configurations Routes - Environment URL - ----------- --- - test https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com - prod https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com + Environment Ingress URL + ----------- ------- --- + test environment https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com + prod internet https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com Variables @@ -90,12 +90,37 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { }, wantedError: fmt.Errorf("retrieve service configuration: some error"), }, + "return error if fail to get service url": { + shouldOutputResources: true, + setupMocks: func(m apprunnerSvcDescriberMocks) { + gomock.InOrder( + m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), + m.ecsSvcDescriber.EXPECT().Service().Return(&apprunner.Service{}, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("", mockErr), + ) + }, + wantedError: fmt.Errorf("retrieve service url: some error"), + }, + "return error if fail to check if private": { + shouldOutputResources: true, + setupMocks: func(m apprunnerSvcDescriberMocks) { + gomock.InOrder( + m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), + m.ecsSvcDescriber.EXPECT().Service().Return(&apprunner.Service{}, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, mockErr), + ) + }, + wantedError: fmt.Errorf("check if service is private: some error"), + }, "return error if fail to retrieve service resources": { shouldOutputResources: true, setupMocks: func(m apprunnerSvcDescriberMocks) { gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsSvcDescriber.EXPECT().Service().Return(&apprunner.Service{}, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, nil), m.ecsSvcDescriber.EXPECT().ServiceStackResources().Return(nil, mockErr), ) }, @@ -119,6 +144,8 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { }, }, }, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(true, nil), m.ecsSvcDescriber.EXPECT().ServiceStackResources().Return([]*stack.Resource{ { Type: "AWS::AppRunner::Service", @@ -138,6 +165,8 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { }, }, }, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, nil), m.ecsSvcDescriber.EXPECT().ServiceStackResources().Return([]*stack.Resource{ { Type: "AWS::AppRunner::Service", @@ -164,14 +193,16 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { Port: "80", }, }, - Routes: []*WebServiceRoute{ + Routes: []*RDWSRoute{ { Environment: "test", URL: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressEnvironment, }, { Environment: "prod", URL: "https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressInternet, }, }, Variables: []*envVar{ @@ -226,6 +257,8 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { }, }, }, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(true, nil), m.ecsSvcDescriber.EXPECT().ServiceStackResources().Return([]*stack.Resource{ { Type: "AWS::AppRunner::Service", @@ -245,6 +278,8 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { }, }, }, nil), + m.ecsSvcDescriber.EXPECT().ServiceURL().Return("https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, nil), m.ecsSvcDescriber.EXPECT().ServiceStackResources().Return([]*stack.Resource{ { Type: "AWS::AppRunner::Service", @@ -271,14 +306,16 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { Port: "80", }, }, - Routes: []*WebServiceRoute{ + Routes: []*RDWSRoute{ { Environment: "test", URL: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressEnvironment, }, { Environment: "prod", URL: "https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressInternet, }, }, Variables: []*envVar{ @@ -362,7 +399,7 @@ func TestRDWebServiceDescriber_Describe(t *testing.T) { func TestRDWebServiceDesc_String(t *testing.T) { t.Run("correct output including resources", func(t *testing.T) { wantedHumanString := humanStringWithResources - wantedJSONString := "{\"service\":\"testsvc\",\"type\":\"Request-Driven Web Service\",\"application\":\"testapp\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"1024\",\"memory\":\"2048\"},{\"environment\":\"prod\",\"port\":\"80\",\"cpu\":\"2048\",\"memory\":\"3072\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com\"},{\"environment\":\"prod\",\"url\":\"https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-prod-testsvc\"}],\"test\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-test-testsvc\"}]}}\n" + wantedJSONString := "{\"service\":\"testsvc\",\"type\":\"Request-Driven Web Service\",\"application\":\"testapp\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"1024\",\"memory\":\"2048\"},{\"environment\":\"prod\",\"port\":\"80\",\"cpu\":\"2048\",\"memory\":\"3072\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com\",\"ingress\":\"environment\"},{\"environment\":\"prod\",\"url\":\"https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com\",\"ingress\":\"internet\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-prod-testsvc\"}],\"test\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-test-testsvc\"}]}}\n" svcDesc := &rdWebSvcDesc{ Service: "testsvc", Type: "Request-Driven Web Service", @@ -381,14 +418,16 @@ func TestRDWebServiceDesc_String(t *testing.T) { Port: "80", }, }, - Routes: []*WebServiceRoute{ + Routes: []*RDWSRoute{ { Environment: "test", URL: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressEnvironment, }, { Environment: "prod", URL: "https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressInternet, }, }, Variables: []*envVar{ @@ -449,10 +488,10 @@ Observability Routes - Environment URL - ----------- --- - test https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com - prod https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com + Environment Ingress URL + ----------- ------- --- + test environment https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com + prod internet https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com Variables @@ -469,7 +508,7 @@ Resources prod AWS::AppRunner::Service arn:aws:apprunner:us-east-1:111111111111:service/testapp-prod-testsvc ` - wantedJSONString := "{\"service\":\"testsvc\",\"type\":\"Request-Driven Web Service\",\"application\":\"testapp\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"1024\",\"memory\":\"2048\"},{\"environment\":\"prod\",\"port\":\"80\",\"cpu\":\"2048\",\"memory\":\"3072\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com\"},{\"environment\":\"prod\",\"url\":\"https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-prod-testsvc\"}],\"test\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-test-testsvc\"}]},\"observability\":[{\"environment\":\"test\",\"tracing\":{\"vendor\":\"mockVendor\"}},{\"environment\":\"prod\"}]}\n" + wantedJSONString := "{\"service\":\"testsvc\",\"type\":\"Request-Driven Web Service\",\"application\":\"testapp\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"1024\",\"memory\":\"2048\"},{\"environment\":\"prod\",\"port\":\"80\",\"cpu\":\"2048\",\"memory\":\"3072\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com\",\"ingress\":\"environment\"},{\"environment\":\"prod\",\"url\":\"https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com\",\"ingress\":\"internet\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-prod-testsvc\"}],\"test\":[{\"type\":\"AWS::AppRunner::Service\",\"physicalID\":\"arn:aws:apprunner:us-east-1:111111111111:service/testapp-test-testsvc\"}]},\"observability\":[{\"environment\":\"test\",\"tracing\":{\"vendor\":\"mockVendor\"}},{\"environment\":\"prod\"}]}\n" svcDesc := &rdWebSvcDesc{ Service: "testsvc", Type: "Request-Driven Web Service", @@ -488,14 +527,16 @@ Resources Port: "80", }, }, - Routes: []*WebServiceRoute{ + Routes: []*RDWSRoute{ { Environment: "test", URL: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressEnvironment, }, { Environment: "prod", URL: "https://tumkjmvjjf.public.us-east-1.apprunner.amazonaws.com", + Ingress: rdwsIngressInternet, }, }, Variables: []*envVar{ diff --git a/internal/pkg/describe/service.go b/internal/pkg/describe/service.go index 2484554741c..dcc0240e7f5 100644 --- a/internal/pkg/describe/service.go +++ b/internal/pkg/describe/service.go @@ -4,6 +4,7 @@ package describe import ( + "errors" "fmt" "io" "net/url" @@ -28,7 +29,10 @@ const ( waitConditionHandle = "AWS::CloudFormation::WaitConditionHandle" ) -const apprunnerServiceType = "AWS::AppRunner::Service" +const ( + apprunnerServiceType = "AWS::AppRunner::Service" + apprunnerVPCIngressConnectionType = "AWS::AppRunner::VpcIngressConnection" +) // ConfigStoreSvc wraps methods of config store. type ConfigStoreSvc interface { @@ -51,7 +55,8 @@ type ecsClient interface { } type apprunnerClient interface { - DescribeService(svcArn string) (*apprunner.Service, error) + DescribeService(svcARN string) (*apprunner.Service, error) + PrivateURL(vicARN string) (string, error) } type workloadStackDescriber interface { @@ -75,6 +80,7 @@ type apprunnerDescriber interface { Service() (*apprunner.Service, error) ServiceARN() (string, error) ServiceURL() (string, error) + IsPrivate() (bool, error) } // serviceStackDescriber provides base functionality for retrieving info about a service. @@ -280,6 +286,24 @@ func (d *appRunnerServiceDescriber) ServiceARN() (string, error) { return "", fmt.Errorf("no App Runner Service in service stack") } +// vpcIngressConnectionARN returns the ARN of the VPC Ingress Connection +// for this service. If one does not exist, it returns errVPCIngressConnectionNotFound. +func (d *appRunnerServiceDescriber) vpcIngressConnectionARN() (string, error) { + serviceStackResources, err := d.ServiceStackResources() + if err != nil { + return "", err + } + + for _, resource := range serviceStackResources { + arn := resource.PhysicalID + if resource.Type == apprunnerVPCIngressConnectionType && arn != "" { + return arn, nil + } + } + + return "", errVPCIngressConnectionNotFound +} + // Service retrieves an app runner service. func (d *appRunnerServiceDescriber) Service() (*apprunner.Service, error) { serviceARN, err := d.ServiceARN() @@ -294,17 +318,44 @@ func (d *appRunnerServiceDescriber) Service() (*apprunner.Service, error) { return service, nil } +// IsPrivate returns true if the service is configured as non-public. +func (d *appRunnerServiceDescriber) IsPrivate() (bool, error) { + _, err := d.vpcIngressConnectionARN() + if err != nil { + if errors.Is(err, errVPCIngressConnectionNotFound) { + return false, nil + } + + return false, err + } + + return true, nil +} + // ServiceURL retrieves the app runner service URL. func (d *appRunnerServiceDescriber) ServiceURL() (string, error) { + vicARN, err := d.vpcIngressConnectionARN() + isVICNotFound := errors.Is(err, errVPCIngressConnectionNotFound) + if err != nil && !isVICNotFound { + return "", err + } + + if !isVICNotFound { + url, err := d.apprunnerClient.PrivateURL(vicARN) + if err != nil { + return "", err + } + return formatAppRunnerURL(url), nil + } + service, err := d.Service() if err != nil { return "", err } - - return formatAppRunnerUrl(service.ServiceURL), nil + return formatAppRunnerURL(service.ServiceURL), nil } -func formatAppRunnerUrl(serviceURL string) string { +func formatAppRunnerURL(serviceURL string) string { svcUrl := &url.URL{ Host: serviceURL, // App Runner defaults to https diff --git a/internal/pkg/describe/service_test.go b/internal/pkg/describe/service_test.go index d14e807cccd..c0a475d4893 100644 --- a/internal/pkg/describe/service_test.go +++ b/internal/pkg/describe/service_test.go @@ -11,6 +11,7 @@ import ( ecsapi "github.com/aws/aws-sdk-go/service/ecs" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/copilot-cli/internal/pkg/aws/apprunner" "github.com/aws/copilot-cli/internal/pkg/aws/ecs" awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" "github.com/aws/copilot-cli/internal/pkg/describe/mocks" @@ -553,3 +554,183 @@ func TestServiceDescriber_Platform(t *testing.T) { }) } } + +type apprunnerMocks struct { + apprunnerClient *mocks.MockapprunnerClient + stackDescriber *mocks.MockstackDescriber +} + +func TestAppRunnerServiceDescriber_ServiceURL(t *testing.T) { + mockErr := errors.New("some error") + mockVICARN := "mockVICARN" + mockServiceARN := "mockServiceARN" + tests := map[string]struct { + setupMocks func(m apprunnerMocks) + + expected string + expectedErr string + }{ + "get ingress connection error": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return(nil, mockErr) + }, + expectedErr: "some error", + }, + "get private url error": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: apprunnerVPCIngressConnectionType, + PhysicalID: mockVICARN, + }, + }, nil) + m.apprunnerClient.EXPECT().PrivateURL(mockVICARN).Return("", mockErr) + }, + expectedErr: "some error", + }, + "private service, success": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: apprunnerVPCIngressConnectionType, + PhysicalID: mockVICARN, + }, + }, nil) + m.apprunnerClient.EXPECT().PrivateURL(mockVICARN).Return("example.com", nil) + }, + expected: "https://example.com", + }, + "public service, resources fails": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return(nil, nil) + m.stackDescriber.EXPECT().Resources().Return(nil, mockErr) + }, + expectedErr: "some error", + }, + "public service, no app runner resource": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: "random", + PhysicalID: "random", + }, + }, nil) + }, + expectedErr: "no App Runner Service in service stack", + }, + "public service, describe service fails": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: apprunnerServiceType, + PhysicalID: mockServiceARN, + }, + }, nil) + m.apprunnerClient.EXPECT().DescribeService(mockServiceARN).Return(nil, mockErr) + }, + expectedErr: "describe service: some error", + }, + "public service, success": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: apprunnerServiceType, + PhysicalID: mockServiceARN, + }, + }, nil) + m.apprunnerClient.EXPECT().DescribeService(mockServiceARN).Return(&apprunner.Service{ + ServiceURL: "example.com", + }, nil) + }, + expected: "https://example.com", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := apprunnerMocks{ + apprunnerClient: mocks.NewMockapprunnerClient(ctrl), + stackDescriber: mocks.NewMockstackDescriber(ctrl), + } + tc.setupMocks(m) + + d := &appRunnerServiceDescriber{ + serviceStackDescriber: &serviceStackDescriber{ + cfn: m.stackDescriber, + }, + apprunnerClient: m.apprunnerClient, + } + + url, err := d.ServiceURL() + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, url) + } + }) + } +} + +func TestAppRunnerServiceDescriber_IsPrivate(t *testing.T) { + mockErr := errors.New("some error") + tests := map[string]struct { + setupMocks func(m apprunnerMocks) + + expected bool + expectedErr string + }{ + "get resources error": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return(nil, mockErr) + }, + expectedErr: "some error", + }, + "is not private": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return(nil, nil) + }, + expected: false, + }, + "is private": { + setupMocks: func(m apprunnerMocks) { + m.stackDescriber.EXPECT().Resources().Return([]*stack.Resource{ + { + Type: apprunnerVPCIngressConnectionType, + PhysicalID: "arn", + }, + }, nil) + }, + expected: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := apprunnerMocks{ + stackDescriber: mocks.NewMockstackDescriber(ctrl), + } + tc.setupMocks(m) + + d := &appRunnerServiceDescriber{ + serviceStackDescriber: &serviceStackDescriber{ + cfn: m.stackDescriber, + }, + } + + isPrivate, err := d.IsPrivate() + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, isPrivate) + } + }) + } +} diff --git a/internal/pkg/describe/uri.go b/internal/pkg/describe/uri.go index b9e4bd47529..6195296c575 100644 --- a/internal/pkg/describe/uri.go +++ b/internal/pkg/describe/uri.go @@ -315,12 +315,22 @@ func (d *RDWebServiceDescriber) URI(envName string) (URI, error) { serviceURL, err := describer.ServiceURL() if err != nil { - return URI{}, fmt.Errorf("get outputs for service %s: %w", d.svc, err) + return URI{}, fmt.Errorf("get outputs for service %q: %w", d.svc, err) + } + + isPrivate, err := describer.IsPrivate() + if err != nil { + return URI{}, fmt.Errorf("check if service %q is private: %w", d.svc, err) + } + + accessType := URIAccessTypeInternet + if isPrivate { + accessType = URIAccessTypeInternal } return URI{ URI: serviceURL, - AccessType: URIAccessTypeInternet, + AccessType: accessType, }, nil } diff --git a/internal/pkg/describe/uri_test.go b/internal/pkg/describe/uri_test.go index 1c4450ab91b..cc98c9cf9c2 100644 --- a/internal/pkg/describe/uri_test.go +++ b/internal/pkg/describe/uri_test.go @@ -492,7 +492,7 @@ func TestRDWebServiceDescriber_URI(t *testing.T) { testCases := map[string]struct { setupMocks func(mocks apprunnerSvcDescriberMocks) - wantedURI string + wantedURI URI wantedError error }{ "fail to get outputs of service stack": { @@ -501,16 +501,40 @@ func TestRDWebServiceDescriber_URI(t *testing.T) { m.ecsSvcDescriber.EXPECT().ServiceURL().Return("", mockErr), ) }, - wantedError: fmt.Errorf("get outputs for service frontend: some error"), + wantedError: fmt.Errorf(`get outputs for service "frontend": some error`), }, - "succeed in getting outputs of service stack": { + "fail to check if private": { setupMocks: func(m apprunnerSvcDescriberMocks) { gomock.InOrder( m.ecsSvcDescriber.EXPECT().ServiceURL().Return(testSvcURL, nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, mockErr), ) }, - - wantedURI: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + wantedError: fmt.Errorf(`check if service "frontend" is private: some error`), + }, + "succeed in getting public service uri": { + setupMocks: func(m apprunnerSvcDescriberMocks) { + gomock.InOrder( + m.ecsSvcDescriber.EXPECT().ServiceURL().Return(testSvcURL, nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(false, nil), + ) + }, + wantedURI: URI{ + URI: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + AccessType: URIAccessTypeInternet, + }, + }, + "succeed in getting private service uri": { + setupMocks: func(m apprunnerSvcDescriberMocks) { + gomock.InOrder( + m.ecsSvcDescriber.EXPECT().ServiceURL().Return(testSvcURL, nil), + m.ecsSvcDescriber.EXPECT().IsPrivate().Return(true, nil), + ) + }, + wantedURI: URI{ + URI: "https://6znxd4ra33.public.us-east-1.apprunner.amazonaws.com", + AccessType: URIAccessTypeInternal, + }, }, } @@ -541,7 +565,7 @@ func TestRDWebServiceDescriber_URI(t *testing.T) { require.EqualError(t, err, tc.wantedError.Error()) } else { require.NoError(t, err) - require.Equal(t, tc.wantedURI, actual.URI) + require.Equal(t, tc.wantedURI, actual) } }) } diff --git a/internal/pkg/initialize/workload.go b/internal/pkg/initialize/workload.go index 2ef4c6d822c..acb4600c3cc 100644 --- a/internal/pkg/initialize/workload.go +++ b/internal/pkg/initialize/workload.go @@ -84,6 +84,7 @@ type ServiceProps struct { WorkloadProps Port uint16 HealthCheck manifest.ContainerHealthCheck + Private bool appDomain *string } @@ -344,6 +345,7 @@ func (w *WorkloadInitializer) newRequestDrivenWebServiceManifest(i *ServiceProps }, Port: i.Port, Platform: i.Platform, + Private: i.Private, } return manifest.NewRequestDrivenWebService(props) } diff --git a/internal/pkg/manifest/rd_web_svc.go b/internal/pkg/manifest/rd_web_svc.go index dfae5817d28..b508e2bf7f9 100644 --- a/internal/pkg/manifest/rd_web_svc.go +++ b/internal/pkg/manifest/rd_web_svc.go @@ -81,8 +81,19 @@ func (c *rdwsVpcConfig) isEmpty() bool { // RequestDrivenWebServiceHttpConfig represents options for configuring http. type RequestDrivenWebServiceHttpConfig struct { - HealthCheckConfiguration HealthCheckArgsOrString `yaml:"healthcheck"` - Alias *string `yaml:"alias"` + HealthCheckConfiguration HealthCheckArgsOrString `yaml:"healthcheck"` + Alias *string `yaml:"alias"` + Private Union[bool, VPCEndpoint] `yaml:"private"` +} + +// VPCEndpoint is used to configure a pre-existing VPC endpoint. +type VPCEndpoint struct { + Endpoint *string `yaml:"endpoint"` +} + +// IsZero implements yaml.IsZeroer. +func (v VPCEndpoint) IsZero() bool { + return v.Endpoint == nil } // AppRunnerInstanceConfig contains the instance configuration properties for an App Runner service. @@ -97,6 +108,7 @@ type RequestDrivenWebServiceProps struct { *WorkloadProps Port uint16 Platform PlatformArgsOrString + Private bool } // NewRequestDrivenWebService creates a new Request-Driven Web Service manifest with default values. @@ -107,6 +119,10 @@ func NewRequestDrivenWebService(props *RequestDrivenWebServiceProps) *RequestDri svc.RequestDrivenWebServiceConfig.ImageConfig.Image.Build.BuildArgs.Dockerfile = stringP(props.Dockerfile) svc.RequestDrivenWebServiceConfig.ImageConfig.Port = aws.Uint16(props.Port) svc.RequestDrivenWebServiceConfig.InstanceConfig.Platform = props.Platform + if props.Private { + svc.Private = BasicToUnion[bool, VPCEndpoint](true) + svc.Network.VPC.Placement.PlacementString = (*PlacementString)(aws.String("private")) + } svc.parser = template.New() return svc } diff --git a/internal/pkg/manifest/union.go b/internal/pkg/manifest/union.go index e37545f2cb7..719938be56f 100644 --- a/internal/pkg/manifest/union.go +++ b/internal/pkg/manifest/union.go @@ -52,12 +52,12 @@ func AdvancedToUnion[Basic, Advanced any](val Advanced) Union[Basic, Advanced] { } // IsBasic returns true if the underlying value of t is type Basic. -func (t *Union[_, _]) IsBasic() bool { +func (t Union[_, _]) IsBasic() bool { return t.isBasic } // IsAdvanced returns true if the underlying value of t is type Advanced. -func (t *Union[_, _]) IsAdvanced() bool { +func (t Union[_, _]) IsAdvanced() bool { return t.isAdvanced } diff --git a/internal/pkg/manifest/validate.go b/internal/pkg/manifest/validate.go index 83398480013..993ead1a9c3 100644 --- a/internal/pkg/manifest/validate.go +++ b/internal/pkg/manifest/validate.go @@ -1388,7 +1388,14 @@ func (r AppRunnerInstanceConfig) validate() error { // validate returns nil if RequestDrivenWebServiceHttpConfig is configured correctly. func (r RequestDrivenWebServiceHttpConfig) validate() error { - return r.HealthCheckConfiguration.validate() + if err := r.HealthCheckConfiguration.validate(); err != nil { + return err + } + return r.Private.validate() +} + +func (v VPCEndpoint) validate() error { + return nil } // validate returns nil if Observability is configured correctly. diff --git a/internal/pkg/template/env.go b/internal/pkg/template/env.go index 2965ac26dcc..ca8fe0135fa 100644 --- a/internal/pkg/template/env.go +++ b/internal/pkg/template/env.go @@ -18,35 +18,38 @@ const ( // Available env-controller managed feature names. const ( - ALBFeatureName = "ALBWorkloads" - EFSFeatureName = "EFSWorkloads" - NATFeatureName = "NATWorkloads" - InternalALBFeatureName = "InternalALBWorkloads" - AliasesFeatureName = "Aliases" + ALBFeatureName = "ALBWorkloads" + EFSFeatureName = "EFSWorkloads" + NATFeatureName = "NATWorkloads" + InternalALBFeatureName = "InternalALBWorkloads" + AliasesFeatureName = "Aliases" + AppRunnerPrivateServiceFeatureName = "AppRunnerPrivateWorkloads" ) // LastForceDeployIDOutputName is the logical ID of the deployment controller output. const LastForceDeployIDOutputName = "LastForceDeployID" var friendlyEnvFeatureName = map[string]string{ - ALBFeatureName: "ALB", - EFSFeatureName: "EFS", - NATFeatureName: "NAT Gateway", - InternalALBFeatureName: "Internal ALB", - AliasesFeatureName: "Aliases", + ALBFeatureName: "ALB", + EFSFeatureName: "EFS", + NATFeatureName: "NAT Gateway", + InternalALBFeatureName: "Internal ALB", + AliasesFeatureName: "Aliases", + AppRunnerPrivateServiceFeatureName: "App Runner Private Services", } var leastVersionForFeature = map[string]string{ - ALBFeatureName: "v1.0.0", - EFSFeatureName: "v1.3.0", - NATFeatureName: "v1.3.0", - InternalALBFeatureName: "v1.10.0", - AliasesFeatureName: "v1.4.0", + ALBFeatureName: "v1.0.0", + EFSFeatureName: "v1.3.0", + NATFeatureName: "v1.3.0", + InternalALBFeatureName: "v1.10.0", + AliasesFeatureName: "v1.4.0", + AppRunnerPrivateServiceFeatureName: "v1.23.0", } // AvailableEnvFeatures returns a list of the latest available feature, named after their corresponding parameter names. func AvailableEnvFeatures() []string { - return []string{ALBFeatureName, EFSFeatureName, NATFeatureName, InternalALBFeatureName, AliasesFeatureName} + return []string{ALBFeatureName, EFSFeatureName, NATFeatureName, InternalALBFeatureName, AliasesFeatureName, AppRunnerPrivateServiceFeatureName} } // FriendlyEnvFeatureName returns a user-friendly feature name given a env-controller managed parameter name. @@ -78,6 +81,7 @@ var ( "bootstrap-resources", "elb-access-logs", "mappings-regional-configs", + "ar-vpc-connector", } ) @@ -263,6 +267,14 @@ func withEnvParsingFuncs() ParseOption { "inc": IncFunc, "fmtSlice": FmtSliceFunc, "quote": strconv.Quote, + "truncate": truncate, }) } } + +func truncate(s string, maxLen int) string { + if len(s) < maxLen { + return s + } + return s[:maxLen] +} diff --git a/internal/pkg/template/env_test.go b/internal/pkg/template/env_test.go index 840d4b3f703..d09e2286637 100644 --- a/internal/pkg/template/env_test.go +++ b/internal/pkg/template/env_test.go @@ -26,6 +26,7 @@ func TestTemplate_ParseEnv(t *testing.T) { "templates/environment/partials/bootstrap-resources.yml": []byte("bootstrap"), "templates/environment/partials/elb-access-logs.yml": []byte("elb-access-logs"), "templates/environment/partials/mappings-regional-configs.yml": []byte("mappings-regional-configs"), + "templates/environment/partials/ar-vpc-connector.yml": []byte("ar-vpc-connector"), }, }, } @@ -58,3 +59,39 @@ func TestTemplate_ParseEnvBootstrap(t *testing.T) { require.NoError(t, err) require.Equal(t, "test", c.String()) } + +func TestTruncate(t *testing.T) { + tests := map[string]struct { + s string + maxLen int + + expected string + }{ + "empty string": { + s: "", + maxLen: 10, + expected: "", + }, + "maxLen < len(string)": { + s: "qwerty", + maxLen: 4, + expected: "qwer", + }, + "maxLen > len(string)": { + s: "qwerty", + maxLen: 7, + expected: "qwerty", + }, + "maxLen == len(string)": { + s: "qwerty", + maxLen: 6, + expected: "qwerty", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.expected, truncate(tc.s, tc.maxLen)) + }) + } +} diff --git a/internal/pkg/template/templates/environment/cf.yml b/internal/pkg/template/templates/environment/cf.yml index 3fb233c3aab..c79fe08da36 100644 --- a/internal/pkg/template/templates/environment/cf.yml +++ b/internal/pkg/template/templates/environment/cf.yml @@ -20,6 +20,8 @@ Parameters: Type: String NATWorkloads: Type: String + AppRunnerPrivateWorkloads: + Type: String ToolsAccountPrincipalARN: Type: String AppDNSName: @@ -53,6 +55,8 @@ Conditions: !Not [!Equals [ !Ref EFSWorkloads, ""]] CreateNATGateways: !Not [!Equals [ !Ref NATWorkloads, ""]] + CreateAppRunnerVPCEndpoint: + !Not [!Equals [ !Ref AppRunnerPrivateWorkloads, ""]] ManagedAliases: !And - !Condition DelegateDNS - !Not [!Equals [ !Ref Aliases, "" ]] @@ -577,6 +581,7 @@ Resources: {{include "lambdas" . | indent 2}} {{include "custom-resources" . | indent 2}} {{- end}} +{{include "ar-vpc-connector" . | indent 2}} {{- if .VPCConfig.FlowLogs }} FlowLog: Metadata: @@ -788,7 +793,7 @@ Outputs: Name: !Sub ${AWS::StackName}-SubDomain {{- end}} EnabledFeatures: - Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases}' + Value: !Sub '${ALBWorkloads},${InternalALBWorkloads},${EFSWorkloads},${NATWorkloads},${Aliases},${AppRunnerPrivateWorkloads}' Description: Required output to force the stack to update if mutating feature params, like ALBWorkloads, does not change the template. ManagedFileSystemID: Condition: CreateEFS @@ -814,3 +819,9 @@ Outputs: LastForceDeployID: Value: {{quote .ForceUpdateID}} Description: Optionally force the template to update when no immediate resource change is present. + AppRunnerVpcEndpointId: + Condition: CreateAppRunnerVPCEndpoint + Value: !Ref AppRunnerVpcEndpoint + Description: VPC Endpoint to App Runner for private services + Export: + Name: !Sub ${AWS::StackName}-AppRunnerVpcEndpointId diff --git a/internal/pkg/template/templates/environment/partials/ar-vpc-connector.yml b/internal/pkg/template/templates/environment/partials/ar-vpc-connector.yml new file mode 100644 index 00000000000..1619af2cb1e --- /dev/null +++ b/internal/pkg/template/templates/environment/partials/ar-vpc-connector.yml @@ -0,0 +1,40 @@ +AppRunnerVpcEndpointSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for App Runner private services' + Type: AWS::EC2::SecurityGroup + Condition: CreateAppRunnerVPCEndpoint + Properties: + GroupDescription: {{truncate (printf "%s-%s-AppRunnerVpcEndpointSecurityGroup" .AppName .EnvName) 255}} + {{- if .VPCConfig.Imported}} + VpcId: {{.VPCConfig.Imported.ID}} + {{- else}} + VpcId: !Ref VPC + {{- end}} + Tags: + - Key: Name + Value: {{truncate (printf "copilot-%s-%s-app-runner-vpc-endpoint" .AppName .EnvName) 255}} + +AppRunnerVpcEndpointSecurityGroupIngressFromEnvironment: + Type: AWS::EC2::SecurityGroupIngress + Condition: CreateAppRunnerVPCEndpoint + Properties: + Description: Ingress from services in the environment + GroupId: !Ref AppRunnerVpcEndpointSecurityGroup + IpProtocol: -1 + SourceSecurityGroupId: !Ref EnvironmentSecurityGroup + +AppRunnerVpcEndpoint: + Metadata: + 'aws:copilot:description': 'VPC Endpoint to connect environment to App Runner for private services' + Type: AWS::EC2::VPCEndpoint + Condition: CreateAppRunnerVPCEndpoint + Properties: + VpcEndpointType: Interface + VpcId: !Ref VPC + SecurityGroupIds: + - !Ref AppRunnerVpcEndpointSecurityGroup + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.apprunner.requests' + SubnetIds: + {{- range $ind, $cidr := .VPCConfig.Managed.PrivateSubnetCIDRs}} + - !Ref PrivateSubnet{{inc $ind}} + {{- end}} \ No newline at end of file diff --git a/internal/pkg/template/templates/environment/partials/cfn-execution-role.yml b/internal/pkg/template/templates/environment/partials/cfn-execution-role.yml index 1cc57e8e098..f404d4e137a 100644 --- a/internal/pkg/template/templates/environment/partials/cfn-execution-role.yml +++ b/internal/pkg/template/templates/environment/partials/cfn-execution-role.yml @@ -28,15 +28,13 @@ CloudformationExecutionRole: PolicyDocument: Version: '2012-10-17' Statement: - - - Effect: Allow + - Effect: Allow NotAction: - 'organizations:*' - 'account:*' Resource: '*' - - - Effect: Allow + - Effect: Allow Action: - 'organizations:DescribeOrganization' - 'account:ListRegions' - Resource: '*' + Resource: '*' \ No newline at end of file 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 39f0c89d4d2..380692d9f06 100644 --- a/internal/pkg/template/templates/environment/partials/environment-manager-role.yml +++ b/internal/pkg/template/templates/environment/partials/environment-manager-role.yml @@ -265,7 +265,8 @@ EnvironmentManagerRole: "apprunner:PauseService", "apprunner:ResumeService", "apprunner:StartDeployment", - "apprunner:DescribeObservabilityConfiguration" + "apprunner:DescribeObservabilityConfiguration", + "apprunner:DescribeVpcIngressConnection" ] Resource: "*" - Sid: Tags diff --git a/internal/pkg/template/templates/workloads/partials/cf/instancerole.yml b/internal/pkg/template/templates/workloads/partials/cf/instancerole.yml index 6e036199e11..b530946fcca 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/instancerole.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/instancerole.yml @@ -19,7 +19,8 @@ InstanceRole: Statement: - Effect: Allow Principal: - Service: tasks.apprunner.amazonaws.com + Service: + - tasks.apprunner.amazonaws.com Action: 'sts:AssumeRole' Policies: - PolicyName: 'DenyIAMExceptTaggedRoles' diff --git a/internal/pkg/template/templates/workloads/partials/cf/vpc-connector.yml b/internal/pkg/template/templates/workloads/partials/cf/vpc-connector.yml index f069750d84b..c3c0937b225 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/vpc-connector.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/vpc-connector.yml @@ -16,7 +16,7 @@ EnvironmentSecurityGroupIngressFromServiceSecurityGroup: Metadata: 'aws:copilot:description': 'Allow ingress from the app runner service to services in your environment' Properties: - GroupId: + GroupId: Fn::ImportValue: !Sub '${AppName}-${EnvName}-EnvironmentSecurityGroup' IpProtocol: -1 @@ -42,7 +42,8 @@ VpcConnector: !Sub '${AppName}-${EnvName}-{{.Network.SubnetsType}}' {{- end}} SecurityGroups: - - !Ref ServiceSecurityGroup + - !Ref ServiceSecurityGroup + - Fn::ImportValue: !Sub '${AppName}-${EnvName}-EnvironmentSecurityGroup' Tags: - Key: copilot-application Value: !Ref AppName diff --git a/internal/pkg/template/templates/workloads/services/rd-web/cf.yml b/internal/pkg/template/templates/workloads/services/rd-web/cf.yml index e19b6304ed1..a4a67f0da7c 100644 --- a/internal/pkg/template/templates/workloads/services/rd-web/cf.yml +++ b/internal/pkg/template/templates/workloads/services/rd-web/cf.yml @@ -133,12 +133,18 @@ Resources: HealthyThreshold: !If [HasHealthCheckHealthyThreshold, !Ref HealthCheckHealthyThreshold, !Ref AWS::NoValue] UnhealthyThreshold: !If [HasHealthCheckUnhealthyThreshold, !Ref HealthCheckUnhealthyThreshold, !Ref AWS::NoValue] {{- end }} - {{- if requiresVPCConnector .}} NetworkConfiguration: EgressConfiguration: + {{- if requiresVPCConnector .}} EgressType: VPC VpcConnectorArn: !Ref VpcConnector - {{- end }} + {{- else }} + EgressType: DEFAULT + {{- end}} + {{- if .Private}} + IngressConfiguration: + IsPubliclyAccessible: false + {{- end}} {{- if eq .Observability.Tracing "AWSXRAY"}} ObservabilityConfiguration: ObservabilityEnabled: true @@ -154,6 +160,36 @@ Resources: - Key: {{$name}} Value: {{$value}}{{end}}{{end}} + {{- if .Private}} + AppRunnerVpcIngressConnection: + Metadata: + 'aws:copilot:description': 'The ingress connection from your environment to this service' + Type: AWS::AppRunner::VpcIngressConnection + Properties: + ServiceArn: !GetAtt Service.ServiceArn + IngressVpcConfiguration: + VpcId: + Fn::ImportValue: !Sub '${AppName}-${EnvName}-VpcId' + {{- if .AppRunnerVPCEndpoint}} + VpcEndpointId: {{.AppRunnerVPCEndpoint}} + {{- else}} + VpcEndpointId: !GetAtt EnvControllerAction.AppRunnerVpcEndpointId + {{- end}} + Tags: + - Key: copilot-application + Value: !Ref AppName + - Key: copilot-environment + Value: !Ref EnvName + - Key: copilot-service + Value: !Ref WorkloadName + {{- if .Tags}} + {{- range $name, $value := .Tags}} + - Key: {{$name}} + Value: {{$value}} + {{- end}} + {{- end}} + {{- end}} + {{include "addons" . | indent 2}} {{if .Alias}} CustomDomainFunction: diff --git a/internal/pkg/template/templates/workloads/services/rd-web/manifest.yml b/internal/pkg/template/templates/workloads/services/rd-web/manifest.yml index 64aef1fcda0..6aa96ec3168 100644 --- a/internal/pkg/template/templates/workloads/services/rd-web/manifest.yml +++ b/internal/pkg/template/templates/workloads/services/rd-web/manifest.yml @@ -19,7 +19,16 @@ image: {{- end}} # Port exposed through your container to route traffic to it. port: {{.ImageConfig.Port}} - +{{if .Private.IsBasic}} +http: + private: {{.Private.Basic}} + # healthcheck: + # path: / + # healthy_threshold: 3 + # unhealthy_threshold: 5 + # interval: 10s + # timeout: 5s +{{- else}} # http: # healthcheck: # path: / @@ -27,16 +36,23 @@ image: # unhealthy_threshold: 5 # interval: 10s # timeout: 5s +{{- end}} # Number of CPU units for the task. cpu: {{.InstanceConfig.CPU}} # Amount of memory in MiB used by the task. memory: {{.InstanceConfig.Memory}} - +{{if .Network.VPC.Placement.PlacementString}} +# Connect your App Runner service to your environment's VPC. +network: + vpc: + placement: {{.Network.VPC.Placement.PlacementString}} +{{else}} # # Connect your App Runner service to your environment's VPC. # network: # vpc: # placement: private +{{end}} # Enable tracing for the service. # observability: diff --git a/internal/pkg/template/workload.go b/internal/pkg/template/workload.go index 9d33608caaf..1fdda7378a5 100644 --- a/internal/pkg/template/workload.go +++ b/internal/pkg/template/workload.go @@ -585,9 +585,11 @@ type WorkloadOpts struct { StateMachine *StateMachineOpts // Additional options for request driven web service templates. - StartCommand *string - EnableHealthCheck bool - Observability ObservabilityOpts + StartCommand *string + EnableHealthCheck bool + Observability ObservabilityOpts + Private bool + AppRunnerVPCEndpoint *string // Input needed for the custom resource that adds a custom domain to the service. Alias *string @@ -732,6 +734,11 @@ func envControllerParameters(o WorkloadOpts) []string { parameters = append(parameters, "InternalALBWorkloads,") } } + if o.WorkloadType == "Request-Driven Web Service" { + if o.Private && o.AppRunnerVPCEndpoint == nil { + parameters = append(parameters, "AppRunnerPrivateWorkloads,") + } + } if o.Network.SubnetsType == PrivateSubnetsPlacement { parameters = append(parameters, "NATWorkloads,") } diff --git a/internal/pkg/template/workload_test.go b/internal/pkg/template/workload_test.go index a9d57c68713..65841deb5af 100644 --- a/internal/pkg/template/workload_test.go +++ b/internal/pkg/template/workload_test.go @@ -7,6 +7,7 @@ import ( "fmt" "testing" + "github.com/aws/aws-sdk-go/aws" "github.com/stretchr/testify/require" ) @@ -332,3 +333,89 @@ func TestWorkload_HealthCheckProtocol(t *testing.T) { }) } } + +func TestEnvControllerParameters(t *testing.T) { + tests := map[string]struct { + opts WorkloadOpts + expected []string + }{ + "LBWS": { + opts: WorkloadOpts{ + WorkloadType: "Load Balanced Web Service", + }, + expected: []string{"Aliases,"}, + }, + "LBWS with ALB": { + opts: WorkloadOpts{ + WorkloadType: "Load Balanced Web Service", + ALBEnabled: true, + }, + expected: []string{"ALBWorkloads,", "Aliases,"}, + }, + "LBWS with ALB and private placement": { + opts: WorkloadOpts{ + WorkloadType: "Load Balanced Web Service", + ALBEnabled: true, + Network: NetworkOpts{ + SubnetsType: PrivateSubnetsPlacement, + }, + }, + expected: []string{"ALBWorkloads,", "Aliases,", "NATWorkloads,"}, + }, + "LBWS with ALB, private placement, and storage": { + opts: WorkloadOpts{ + WorkloadType: "Load Balanced Web Service", + ALBEnabled: true, + Network: NetworkOpts{ + SubnetsType: PrivateSubnetsPlacement, + }, + Storage: &StorageOpts{ + ManagedVolumeInfo: &ManagedVolumeCreationInfo{ + Name: aws.String("hi"), + }, + }, + }, + expected: []string{"ALBWorkloads,", "Aliases,", "NATWorkloads,", "EFSWorkloads,"}, + }, + "Backend": { + opts: WorkloadOpts{ + WorkloadType: "Backend Service", + }, + expected: []string{}, + }, + "Backend with ALB": { + opts: WorkloadOpts{ + WorkloadType: "Backend Service", + ALBEnabled: true, + }, + expected: []string{"InternalALBWorkloads,"}, + }, + "RDWS": { + opts: WorkloadOpts{ + WorkloadType: "Request-Driven Web Service", + }, + expected: []string{}, + }, + "private RDWS": { + opts: WorkloadOpts{ + WorkloadType: "Request-Driven Web Service", + Private: true, + }, + expected: []string{"AppRunnerPrivateWorkloads,"}, + }, + "private RDWS with imported VPC Endpoint": { + opts: WorkloadOpts{ + WorkloadType: "Request-Driven Web Service", + Private: true, + AppRunnerVPCEndpoint: aws.String("vpce-1234"), + }, + expected: []string{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.expected, envControllerParameters(tc.opts)) + }) + } +}