diff --git a/internal/pkg/deploy/cloudformation/stack/backend_svc.go b/internal/pkg/deploy/cloudformation/stack/backend_svc.go index 622824ec047..a2da5bc51d4 100644 --- a/internal/pkg/deploy/cloudformation/stack/backend_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/backend_svc.go @@ -33,7 +33,8 @@ type BackendService struct { httpsEnabled bool albEnabled bool - parser backendSvcReadParser + parser backendSvcReadParser + SCFeatureFlag bool } // BackendServiceConfig contains data required to initialize a backend service stack. @@ -141,8 +142,11 @@ func (s *BackendService) Template() (string, error) { for _, ipNet := range s.manifest.RoutingRule.AllowedSourceIps { allowedSourceIPs = append(allowedSourceIPs, string(ipNet)) } - - _, targetContainerPort := s.httpLoadBalancerTarget() + var scConfig *template.ServiceConnect + if s.manifest.ServiceConnectEnabled() { + scConfig = convertServiceConnect(s.manifest.Network.Connect) + } + targetContainer, targetContainerPort := s.httpLoadBalancerTarget() content, err := s.parser.ParseBackendService(template.WorkloadOpts{ AppName: s.app, EnvName: s.env, @@ -166,7 +170,9 @@ func (s *BackendService) Template() (string, error) { HealthCheck: convertContainerHealthCheck(s.manifest.BackendServiceConfig.ImageConfig.HealthCheck), HTTPTargetContainer: template.HTTPTargetContainer{ Port: aws.StringValue(targetContainerPort), + Name: aws.StringValue(targetContainer), }, + ServiceConnect: scConfig, HTTPHealthCheck: convertHTTPHealthCheck(&s.manifest.RoutingRule.HealthCheck), DeregistrationDelay: deregistrationDelay, AllowedSourceIps: allowedSourceIPs, @@ -190,6 +196,7 @@ func (s *BackendService) Template() (string, error) { }, HostedZoneAliases: hostedZoneAliases, PermissionsBoundary: s.permBound, + SCFeatureFlag: s.SCFeatureFlag, }) if err != nil { return "", fmt.Errorf("parse backend service template: %w", err) diff --git a/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go b/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go index e0ad2d2f0d7..0bb472bf5c6 100644 --- a/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go +++ b/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go @@ -271,6 +271,7 @@ Outputs: HostedZoneAliases: make(template.AliasesForHostedZone), HTTPTargetContainer: template.HTTPTargetContainer{ Port: "8080", + Name: "api", }, HTTPHealthCheck: template.HTTPHealthCheckOpts{ HealthCheckPath: manifest.DefaultHealthCheckPath, @@ -287,6 +288,7 @@ Outputs: Key: "sha2/count.zip", }, }, + ServiceConnect: &template.ServiceConnect{}, ExecuteCommand: &template.ExecuteCommandOpts{}, NestedStack: &template.WorkloadNestedStackOpts{ StackName: addon.StackName, @@ -430,13 +432,15 @@ Outputs: }, Sidecars: []*template.SidecarOpts{ { - Name: aws.String("envoy"), + Name: "envoy", Port: aws.String("443"), }, }, HTTPTargetContainer: template.HTTPTargetContainer{ + Name: "envoy", Port: "443", }, + ServiceConnect: &template.ServiceConnect{}, HTTPHealthCheck: template.HTTPHealthCheckOpts{ HealthCheckPath: "/healthz", Port: "4200", @@ -537,14 +541,6 @@ func TestBackendService_Parameters(t *testing.T) { ParameterKey: aws.String(WorkloadContainerPortParamKey), ParameterValue: aws.String("8080"), }, - { - ParameterKey: aws.String(WorkloadTargetContainerParamKey), - ParameterValue: aws.String("frontend"), - }, - { - ParameterKey: aws.String(WorkloadTargetPortParamKey), - ParameterValue: aws.String("8080"), - }, { ParameterKey: aws.String(WorkloadTaskCPUParamKey), ParameterValue: aws.String("256"), @@ -569,6 +565,14 @@ func TestBackendService_Parameters(t *testing.T) { ParameterKey: aws.String(WorkloadEnvFileARNParamKey), ParameterValue: aws.String(""), }, + { + ParameterKey: aws.String(WorkloadTargetContainerParamKey), + ParameterValue: aws.String("frontend"), + }, + { + ParameterKey: aws.String(WorkloadTargetPortParamKey), + ParameterValue: aws.String("8080"), + }, }, params) } @@ -650,6 +654,7 @@ func TestBackendService_TemplateAndParamsGeneration(t *testing.T) { EnvVersion: "v1.42.0", }, }) + serializer.SCFeatureFlag = true require.NoError(t, err) // mock parser for lambda functions diff --git a/internal/pkg/deploy/cloudformation/stack/lb_network_web_service_integration_test.go b/internal/pkg/deploy/cloudformation/stack/lb_network_web_service_integration_test.go index 9f997e833ec..503a3263e3a 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_network_web_service_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_network_web_service_integration_test.go @@ -105,6 +105,7 @@ func TestNetworkLoadBalancedWebService_Template(t *testing.T) { }, RootUserARN: "arn:aws:iam::123456789123:root", }, stack.WithNLB([]string{"10.0.0.0/24", "10.1.0.0/24"})) + serializer.SCFeatureFlag = true tpl, err := serializer.Template() require.NoError(t, err, "template should render") regExpGUID := regexp.MustCompile(`([a-f\d]{8}-)([a-f\d]{4}-){3}([a-f\d]{12})`) // Matches random guids diff --git a/internal/pkg/deploy/cloudformation/stack/lb_web_service_integration_test.go b/internal/pkg/deploy/cloudformation/stack/lb_web_service_integration_test.go index 34042cac16c..59fb5dd04ac 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_web_service_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_web_service_integration_test.go @@ -105,6 +105,7 @@ func TestLoadBalancedWebService_Template(t *testing.T) { EnvVersion: "v1.42.0", }, }) + serializer.SCFeatureFlag = true tpl, err := serializer.Template() require.NoError(t, err, "template should render") regExpGUID := regexp.MustCompile(`([a-f\d]{8}-)([a-f\d]{4}-){3}([a-f\d]{12})`) // Matches random guids diff --git a/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go b/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go index 25916287594..73bf9ed985a 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go @@ -38,7 +38,8 @@ type LoadBalancedWebService struct { publicSubnetCIDRBlocks []string appInfo deploy.AppInformation - parser loadBalancedWebSvcReadParser + parser loadBalancedWebSvcReadParser + SCFeatureFlag bool } // LoadBalancedWebServiceOption is used to configuring an optional field for LoadBalancedWebService. @@ -190,6 +191,10 @@ func (s *LoadBalancedWebService) Template() (string, error) { if s.manifest.RoutingRule.RedirectToHTTPS != nil { httpRedirect = aws.BoolValue(s.manifest.RoutingRule.RedirectToHTTPS) } + var scConfig *template.ServiceConnect + if s.manifest.ServiceConnectEnabled() { + scConfig = convertServiceConnect(s.manifest.Network.Connect) + } targetContainer, targetContainerPort := s.httpLoadBalancerTarget() content, err := s.parser.ParseLoadBalancedWebService(template.WorkloadOpts{ AppName: s.app, @@ -214,9 +219,10 @@ func (s *LoadBalancedWebService) Template() (string, error) { ExecuteCommand: convertExecuteCommand(&s.manifest.ExecuteCommand), WorkloadType: manifest.LoadBalancedWebServiceType, HTTPTargetContainer: template.HTTPTargetContainer{ - Port: aws.StringValue(targetContainerPort), - Container: aws.StringValue(targetContainer), + Port: aws.StringValue(targetContainerPort), + Name: aws.StringValue(targetContainer), }, + ServiceConnect: scConfig, HealthCheck: convertContainerHealthCheck(s.manifest.ImageConfig.HealthCheck), HTTPHealthCheck: convertHTTPHealthCheck(&s.manifest.RoutingRule.HealthCheck), DeregistrationDelay: deregistrationDelay, @@ -242,6 +248,7 @@ func (s *LoadBalancedWebService) Template() (string, error) { }, HostedZoneAliases: aliasesFor, PermissionsBoundary: s.permBound, + SCFeatureFlag: s.SCFeatureFlag, }) if err != nil { return "", err diff --git a/internal/pkg/deploy/cloudformation/stack/lb_web_svc_test.go b/internal/pkg/deploy/cloudformation/stack/lb_web_svc_test.go index 066207281dc..7d9d57e977f 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_web_svc_test.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_web_svc_test.go @@ -207,6 +207,11 @@ Outputs: String: nil, StringSlice: []string{"world"}, } + mft.Network.Connect = manifest.ServiceConnectBoolOrArgs{ + ServiceConnectArgs: manifest.ServiceConnectArgs{ + Alias: aws.String("frontend"), + }, + } var actual template.WorkloadOpts parser := mocks.NewMockloadBalancedWebSvcReadParser(ctrl) @@ -262,8 +267,11 @@ Outputs: HTTPRedirect: true, DeregistrationDelay: aws.Int64(60), HTTPTargetContainer: template.HTTPTargetContainer{ - Container: "frontend", - Port: "80", + Name: "frontend", + Port: "80", + }, + ServiceConnect: &template.ServiceConnect{ + Alias: aws.String("frontend"), }, HealthCheck: &template.ContainerHealthCheck{ Command: []string{"CMD-SHELL", "curl -f http://localhost/ || exit 1"}, @@ -415,8 +423,8 @@ Outputs: // THEN require.NoError(t, err) require.Equal(t, template.HTTPTargetContainer{ - Port: "443", - Container: "envoy", + Port: "443", + Name: "envoy", }, actual.HTTPTargetContainer) }) } diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/manifest.yml index bb95b6f1bb2..be2565b4a81 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/manifest.yml @@ -31,6 +31,8 @@ count: memory_percentage: 80 # Enable running commands in your container. exec: true +network: + connect: false taskdef_overrides: - path: "ContainerDefinitions[0].Ulimits[-].Name" diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-manifest.yml index 0547c19f8f7..f77c95217ce 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-manifest.yml @@ -10,6 +10,9 @@ image: # Port exposed through your container to route traffic to it. port: 8080 +network: + connect: false + cpu: 512 # Number of CPU units for the task. memory: 1024 # Amount of memory in MiB used by the task. exec: true # Enable running commands in your container. diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-manifest.yml index e856a9f9207..409382394aa 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-manifest.yml @@ -16,6 +16,9 @@ http: timeout: 10s grace_period: 45s +network: + connect: false + image: # Docker build arguments. For additional overrides: https://aws.github.io/copilot-cli/docs/manifest/backend-service/#image-build build: Dockerfile diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-manifest.yml index 677fb876bd7..6a0fd564f78 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-manifest.yml @@ -10,6 +10,9 @@ image: # Port exposed through your container to route traffic to it. port: 8080 +network: + connect: false + cpu: 512 # Number of CPU units for the task. memory: 1024 # Amount of memory in MiB used by the task. count: 1 # Number of tasks that should be running in your service. diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-manifest.yml index 91e06831d51..cb80c24ceef 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-manifest.yml @@ -10,6 +10,9 @@ http: - name: "*.foobar.com" hosted_zone: mockHostedZone1 +network: + connect: false + image: # Docker build arguments. For additional overrides: https://aws.github.io/copilot-cli/docs/manifest/backend-service/#image-build build: Dockerfile diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-template.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-template.yml index d15c1f13db2..7757d5601af 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-template.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-template.yml @@ -89,12 +89,7 @@ Resources: awslogs-region: !Ref AWS::Region awslogs-group: !Ref LogGroup awslogs-stream-prefix: copilot - PortMappings: - !If [ - ExposePort, - [{ ContainerPort: !Ref ContainerPort }], - !Ref "AWS::NoValue", - ] + PortMappings: !If [ExposePort, [{ContainerPort: !Ref ContainerPort, Name: target}], !Ref "AWS::NoValue"] ExecutionRole: Metadata: "aws:copilot:description": "An IAM Role for the Fargate agent to make AWS API calls on your behalf" @@ -279,6 +274,22 @@ Resources: PropagateTags: SERVICE EnableExecuteCommand: true LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: True + Namespace: my-env.my-app.local + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: copilot + Services: + - PortName: target + # Avoid using the same service with Service Discovery in a namespace. + DiscoveryName: !Join ["-", [!Ref WorkloadName, "sc"]] + ClientAliases: + - Port: !Ref TargetPort + DnsName: !Ref WorkloadName NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-manifest.yml index 32ecf794a21..5478c9d5504 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-manifest.yml @@ -37,6 +37,9 @@ publish: topics: - name: givesdogs +network: + connect: false + # Optional fields for more advanced use-cases. # variables: # Pass environment variables as key value pairs. diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml index 87c8d6dc759..07ddad01086 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml @@ -60,6 +60,8 @@ environments: path: / grace_period: 30s deregistration_delay: 30s + network: + connect: false prod: count: range: @@ -80,9 +82,16 @@ environments: TEST: TEST secrets: GITHUB_TOKEN: GITHUB_TOKEN + http: + path: '/' + alias: example.com + target_container: nginx + network: + connect: + alias: api sidecars: nginx: - port: 80 + port: 8080 image: 1234567890.dkr.ecr.us-west-2.amazonaws.com/reverse-proxy:revision_1 variables: NGINX_PORT: 80 diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-manifest.yml index 2c2e8e5a404..f6a11dd4f3a 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-manifest.yml @@ -10,6 +10,8 @@ image: build: ./Dockerfile port: 80 http: false +network: + connect: false nlb: port: 443/tls count: 5 @@ -26,6 +28,8 @@ environments: nlb: healthcheck: port: 80 + network: + connect: true dev: http: path: '/' diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-test.stack.yml index 317e9e88e8d..16616e3d6e0 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-test.stack.yml @@ -95,6 +95,7 @@ Resources: # If a bucket URL is specified, that means the template exists. awslogs-stream-prefix: copilot PortMappings: - ContainerPort: !Ref ContainerPort + Name: target - ContainerPort: 443 Protocol: tcp ExecutionRole: @@ -294,6 +295,22 @@ Resources: # If a bucket URL is specified, that means the template exists. MaximumPercent: 200 PropagateTags: SERVICE LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: True + Namespace: test.my-app.local + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: copilot + Services: + - PortName: target + # Avoid using the same service with Service Discovery in a namespace. + DiscoveryName: !Join ["-", [!Ref WorkloadName, "sc"]] + ClientAliases: + - Port: !Ref TargetPort + DnsName: !Ref WorkloadName NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.params.json b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.params.json index 48bae789f8e..25d6ed6f63e 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.params.json +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.params.json @@ -11,8 +11,8 @@ "LogRetention": "1", "RulePath": "/", "Stickiness": "false", - "TargetContainer": "fe", - "TargetPort": "4000", + "TargetContainer": "nginx", + "TargetPort": "8080", "TaskCPU": "256", "TaskCount": "3", "TaskMemory": "512", diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.stack.yml index 7c7f08f223d..8003a5f4009 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-prod.stack.yml @@ -143,7 +143,8 @@ Resources: # If a bucket URL is specified, that means the template exists. - Name: nginx Image: 1234567890.dkr.ecr.us-west-2.amazonaws.com/reverse-proxy:revision_1 PortMappings: - - ContainerPort: 80 + - ContainerPort: 8080 + Name: target HealthCheck: Command: ["CMD-SHELL", "curl -f http://localhost:8080 || exit 1"] Interval: 10 @@ -190,11 +191,11 @@ Resources: # If a bucket URL is specified, that means the template exists. - Name: COPILOT_LB_DNS Value: !GetAtt EnvControllerAction.PublicLoadBalancerDNSName LogConfiguration: - LogDriver: awslogs - Options: - awslogs-region: !Ref AWS::Region - awslogs-group: !Ref LogGroup - awslogs-stream-prefix: copilot + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: copilot Volumes: - Name: persistence ExecutionRole: @@ -518,6 +519,22 @@ Resources: # If a bucket URL is specified, that means the template exists. - CapacityProvider: FARGATE Weight: 0 Base: 5 + ServiceConnectConfiguration: + Enabled: True + Namespace: prod.my-app.local + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: copilot + Services: + - PortName: target + # Avoid using the same service with Service Discovery in a namespace. + DiscoveryName: !Join ["-", [!Ref WorkloadName, "sc"]] + ClientAliases: + - Port: !Ref TargetPort + DnsName: api NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-test.stack.yml index a71f21d5ea2..fd0fc9a4f43 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-test.stack.yml @@ -109,6 +109,7 @@ Resources: # If a bucket URL is specified, that means the template exists. SourceVolume: persistence PortMappings: - ContainerPort: !Ref ContainerPort + Name: target Volumes: - Name: persistence ExecutionRole: @@ -430,6 +431,22 @@ Resources: # If a bucket URL is specified, that means the template exists. MaximumPercent: 200 PropagateTags: SERVICE LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: True + Namespace: test.my-app.local + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: copilot + Services: + - PortName: target + # Avoid using the same service with Service Discovery in a namespace. + DiscoveryName: !Join ["-", [!Ref WorkloadName, "sc"]] + ClientAliases: + - Port: !Ref TargetPort + DnsName: !Ref WorkloadName NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-manifest.yml index 8b8638ab2e9..2138052f58a 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-manifest.yml @@ -21,6 +21,9 @@ image: # Port exposed through your container to route traffic to it. port: 80 +network: + connect: false + cpu: 1024 # Number of CPU units for the task. memory: 2048 # Amount of memory in MiB used by the task. count: 1 # Number of tasks that should be running in your service. diff --git a/internal/pkg/deploy/cloudformation/stack/transformers.go b/internal/pkg/deploy/cloudformation/stack/transformers.go index b69966f058c..88fb9267082 100644 --- a/internal/pkg/deploy/cloudformation/stack/transformers.go +++ b/internal/pkg/deploy/cloudformation/stack/transformers.go @@ -96,7 +96,7 @@ func convertSidecar(s map[string]*manifest.SidecarConfig) ([]*template.SidecarOp } mp := convertSidecarMountPoints(config.MountPoints) sidecars = append(sidecars, &template.SidecarOpts{ - Name: aws.String(name), + Name: name, Image: config.Image, Essential: config.Essential, Port: port, @@ -496,6 +496,12 @@ func convertExecuteCommand(e *manifest.ExecuteCommand) *template.ExecuteCommandO return &template.ExecuteCommandOpts{} } +func convertServiceConnect(s manifest.ServiceConnectBoolOrArgs) *template.ServiceConnect { + return &template.ServiceConnect{ + Alias: s.ServiceConnectArgs.Alias, + } +} + func convertLogging(lc manifest.Logging) *template.LogConfigOpts { if lc.IsEmpty() { return nil diff --git a/internal/pkg/deploy/cloudformation/stack/transformers_test.go b/internal/pkg/deploy/cloudformation/stack/transformers_test.go index 37ac078239d..9baaca2bce0 100644 --- a/internal/pkg/deploy/cloudformation/stack/transformers_test.go +++ b/internal/pkg/deploy/cloudformation/stack/transformers_test.go @@ -44,7 +44,7 @@ func Test_convertSidecar(t *testing.T) { inEssential: true, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", Port: aws.String("2000"), CredsParam: mockCredsParam, Image: mockImage, @@ -58,7 +58,7 @@ func Test_convertSidecar(t *testing.T) { inEssential: true, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", Port: aws.String("2000"), Protocol: aws.String("udp"), CredsParam: mockCredsParam, @@ -76,7 +76,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", Port: aws.String("2000"), CredsParam: mockCredsParam, Image: mockImage, @@ -96,7 +96,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", Port: aws.String("2000"), CredsParam: mockCredsParam, Image: mockImage, @@ -110,7 +110,7 @@ func Test_convertSidecar(t *testing.T) { }, "do not specify image override": { wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, @@ -126,7 +126,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, @@ -142,7 +142,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, @@ -158,7 +158,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, @@ -174,7 +174,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, @@ -190,7 +190,7 @@ func Test_convertSidecar(t *testing.T) { }, wanted: &template.SidecarOpts{ - Name: aws.String("foo"), + Name: "foo", CredsParam: mockCredsParam, Image: mockImage, Secrets: mockSecrets, diff --git a/internal/pkg/deploy/cloudformation/stack/worker_svc.go b/internal/pkg/deploy/cloudformation/stack/worker_svc.go index 73abd19eef4..f40a5f97311 100644 --- a/internal/pkg/deploy/cloudformation/stack/worker_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/worker_svc.go @@ -5,11 +5,11 @@ package stack import ( "fmt" - "github.com/aws/copilot-cli/internal/pkg/config" "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/aws/copilot-cli/internal/pkg/config" "github.com/aws/copilot-cli/internal/pkg/manifest" "github.com/aws/copilot-cli/internal/pkg/template" "github.com/aws/copilot-cli/internal/pkg/template/override" diff --git a/internal/pkg/manifest/backend_svc.go b/internal/pkg/manifest/backend_svc.go index f79f9243db3..e064eabcfc8 100644 --- a/internal/pkg/manifest/backend_svc.go +++ b/internal/pkg/manifest/backend_svc.go @@ -98,6 +98,23 @@ func (s *BackendService) Port() (port uint16, ok bool) { return aws.Uint16Value(value), true } +// ServiceConnectEnabled returns if ServiceConnect is enabled or not. +// Unless explicitly disabled in the manifest or if no server is configured we default to true. +func (s *BackendService) ServiceConnectEnabled() bool { + if s.Network.Connect.EnableServiceConnect != nil { + return *s.Network.Connect.EnableServiceConnect + } + if !s.Network.Connect.ServiceConnectArgs.isEmpty() { + return true + } + // Try our best to enable Service Connect by default. + if s.BackendServiceConfig.ImageConfig.Port != nil || + s.BackendServiceConfig.RoutingRule.TargetContainer != nil { + return true + } + return false +} + // Publish returns the list of topics where notifications can be published. func (s *BackendService) Publish() []Topic { return s.BackendServiceConfig.PublishConfig.publishedTopics() diff --git a/internal/pkg/manifest/backend_svc_test.go b/internal/pkg/manifest/backend_svc_test.go index aaa883bd831..84ac90473cd 100644 --- a/internal/pkg/manifest/backend_svc_test.go +++ b/internal/pkg/manifest/backend_svc_test.go @@ -297,6 +297,83 @@ func TestBackendService_Port(t *testing.T) { } } +func TestBackendService_ServiceConnectEnabled(t *testing.T) { + testCases := map[string]struct { + mft *BackendService + + wanted bool + }{ + "enabled by default if main container exposes port": { + mft: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: ImageWithHealthcheckAndOptionalPort{ + ImageWithOptionalPort: ImageWithOptionalPort{ + Port: uint16P(80), + }, + }, + }, + }, + wanted: true, + }, + "enabled by default if target container is set": { + mft: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + RoutingRule: RoutingRuleConfiguration{ + TargetContainer: aws.String("nginx"), + }, + }, + }, + wanted: true, + }, + "disabled by default if no exposed port or no target container": { + mft: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{}, + }, + }, + }, + wanted: false, + }, + "set by bool": { + mft: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{ + EnableServiceConnect: aws.Bool(true), + }, + }, + }, + }, + wanted: true, + }, + "set by args": { + mft: &BackendService{ + BackendServiceConfig: BackendServiceConfig{ + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{ + ServiceConnectArgs: ServiceConnectArgs{ + Alias: aws.String("api"), + }, + }, + }, + }, + }, + wanted: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // WHEN + enabled := tc.mft.ServiceConnectEnabled() + + // THEN + require.Equal(t, tc.wanted, enabled) + }) + } +} + func TestBackendService_Publish(t *testing.T) { testCases := map[string]struct { mft *BackendService diff --git a/internal/pkg/manifest/lb_web_svc.go b/internal/pkg/manifest/lb_web_svc.go index 7d9b23ecf26..0f6cd323dd0 100644 --- a/internal/pkg/manifest/lb_web_svc.go +++ b/internal/pkg/manifest/lb_web_svc.go @@ -169,6 +169,11 @@ func (s *LoadBalancedWebService) Port() (port uint16, ok bool) { return aws.Uint16Value(s.ImageConfig.Port), true } +// ServiceConnectEnabled returns if ServiceConnect is enabled or not. +func (s *LoadBalancedWebService) ServiceConnectEnabled() bool { + return s.Network.Connect.EnableServiceConnect == nil || *s.Network.Connect.EnableServiceConnect +} + // Publish returns the list of topics where notifications can be published. func (s *LoadBalancedWebService) Publish() []Topic { return s.LoadBalancedWebServiceConfig.PublishConfig.publishedTopics() diff --git a/internal/pkg/manifest/lb_web_svc_test.go b/internal/pkg/manifest/lb_web_svc_test.go index 27ad87879bf..2cf7fcbce31 100644 --- a/internal/pkg/manifest/lb_web_svc_test.go +++ b/internal/pkg/manifest/lb_web_svc_test.go @@ -1338,6 +1338,41 @@ func TestLoadBalancedWebService_Port(t *testing.T) { require.Equal(t, uint16(80), actual) } +func TestLoadBalancedWebService_ServiceConnectEnabled(t *testing.T) { + testCases := map[string]struct { + mft *LoadBalancedWebService + + wanted bool + }{ + "enabled by default": { + mft: &LoadBalancedWebService{}, + wanted: true, + }, + "disable if explicitly set": { + mft: &LoadBalancedWebService{ + LoadBalancedWebServiceConfig: LoadBalancedWebServiceConfig{ + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{ + EnableServiceConnect: aws.Bool(false), + }, + }, + }, + }, + wanted: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // WHEN + enabled := tc.mft.ServiceConnectEnabled() + + // THEN + require.Equal(t, tc.wanted, enabled) + }) + } +} + func TestLoadBalancedWebService_Publish(t *testing.T) { testCases := map[string]struct { mft *LoadBalancedWebService diff --git a/internal/pkg/manifest/svc_test.go b/internal/pkg/manifest/svc_test.go index b0ddf5bc17a..192e6b4bad0 100644 --- a/internal/pkg/manifest/svc_test.go +++ b/internal/pkg/manifest/svc_test.go @@ -41,6 +41,8 @@ cpu: 512 memory: 1024 count: 1 exec: true +network: + connect: true http: path: "svc" target_container: "frontend" @@ -161,6 +163,9 @@ environments: PlacementString: placementStringP(PublicSubnetPlacement), }, }, + Connect: ServiceConnectBoolOrArgs{ + EnableServiceConnect: aws.Bool(true), + }, }, TaskDefOverrides: []OverrideRule{ { diff --git a/internal/pkg/manifest/transform.go b/internal/pkg/manifest/transform.go index b28b294109b..79cc426968c 100644 --- a/internal/pkg/manifest/transform.go +++ b/internal/pkg/manifest/transform.go @@ -23,6 +23,7 @@ var defaultTransformers = []mergo.Transformers{ stringSliceOrStringTransformer{}, platformArgsOrStringTransformer{}, securityGroupsIDsOrConfigTransformer{}, + serviceConnectTransformer{}, placementArgOrStringTransformer{}, subnetListOrArgsTransformer{}, healthCheckArgsOrStringTransformer{}, @@ -238,6 +239,32 @@ func (t placementArgOrStringTransformer) Transformer(typ reflect.Type) func(dst, } } +type serviceConnectTransformer struct{} + +// Transformer returns custom merge logic for serviceConnectTransformer's fields. +func (t serviceConnectTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { + if typ != reflect.TypeOf(ServiceConnectBoolOrArgs{}) { + return nil + } + + return func(dst, src reflect.Value) error { + dstStruct, srcStruct := dst.Interface().(ServiceConnectBoolOrArgs), src.Interface().(ServiceConnectBoolOrArgs) + + if srcStruct.EnableServiceConnect != nil { + dstStruct.ServiceConnectArgs = ServiceConnectArgs{} + } + + if !srcStruct.ServiceConnectArgs.isEmpty() { + dstStruct.EnableServiceConnect = nil + } + + if dst.CanSet() { // For extra safety to prevent panicking. + dst.Set(reflect.ValueOf(dstStruct)) + } + return nil + } +} + type subnetListOrArgsTransformer struct{} // Transformer returns custom merge logic for subnetListOrArgsTransformer's fields. diff --git a/internal/pkg/manifest/transform_test.go b/internal/pkg/manifest/transform_test.go index 0286bfbeaa4..9f7ed4cdf7c 100644 --- a/internal/pkg/manifest/transform_test.go +++ b/internal/pkg/manifest/transform_test.go @@ -592,6 +592,64 @@ func TestSubnetListOrArgsTransformer_Transformer(t *testing.T) { } } +func TestServiceConnectTransformer_Transformer(t *testing.T) { + testCases := map[string]struct { + original func(p *ServiceConnectBoolOrArgs) + override func(p *ServiceConnectBoolOrArgs) + wanted func(p *ServiceConnectBoolOrArgs) + }{ + "bool set to empty if args is not nil": { + original: func(s *ServiceConnectBoolOrArgs) { + s.EnableServiceConnect = aws.Bool(false) + }, + override: func(s *ServiceConnectBoolOrArgs) { + s.ServiceConnectArgs = ServiceConnectArgs{ + Alias: aws.String("api"), + } + }, + wanted: func(s *ServiceConnectBoolOrArgs) { + s.ServiceConnectArgs = ServiceConnectArgs{ + Alias: aws.String("api"), + } + }, + }, + "args set to empty if bool is not nil": { + original: func(s *ServiceConnectBoolOrArgs) { + s.ServiceConnectArgs = ServiceConnectArgs{ + Alias: aws.String("api"), + } + }, + override: func(s *ServiceConnectBoolOrArgs) { + s.EnableServiceConnect = aws.Bool(true) + }, + wanted: func(s *ServiceConnectBoolOrArgs) { + s.EnableServiceConnect = aws.Bool(true) + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var dst, override, wanted ServiceConnectBoolOrArgs + + tc.original(&dst) + tc.override(&override) + tc.wanted(&wanted) + + // Perform default merge. + err := mergo.Merge(&dst, override, mergo.WithOverride) + require.NoError(t, err) + + // Use custom transformer. + err = mergo.Merge(&dst, override, mergo.WithOverride, mergo.WithTransformers(serviceConnectTransformer{})) + require.NoError(t, err) + + require.NoError(t, err) + require.Equal(t, wanted, dst) + }) + } +} + func TestHealthCheckArgsOrStringTransformer_Transformer(t *testing.T) { testCases := map[string]struct { original func(h *HealthCheckArgsOrString) diff --git a/internal/pkg/manifest/validate.go b/internal/pkg/manifest/validate.go index 0c8d511b7f9..bd4a442ee8e 100644 --- a/internal/pkg/manifest/validate.go +++ b/internal/pkg/manifest/validate.go @@ -78,6 +78,7 @@ func (l LoadBalancedWebService) validate() error { } if err = validateTargetContainer(validateTargetContainerOpts{ mainContainerName: aws.StringValue(l.Name), + mainContainerPort: l.ImageConfig.Port, targetContainer: l.RoutingRule.GetTargetContainer(), sidecarConfig: l.Sidecars, }); err != nil { @@ -85,6 +86,7 @@ func (l LoadBalancedWebService) validate() error { } if err = validateTargetContainer(validateTargetContainerOpts{ mainContainerName: aws.StringValue(l.Name), + mainContainerPort: l.ImageConfig.Port, targetContainer: l.NLBConfig.TargetContainer, sidecarConfig: l.Sidecars, }); err != nil { @@ -193,6 +195,19 @@ func (b BackendService) validate() error { if err = b.Workload.validate(); err != nil { return err } + if err = validateTargetContainer(validateTargetContainerOpts{ + mainContainerName: aws.StringValue(b.Name), + mainContainerPort: b.ImageConfig.Port, + targetContainer: b.RoutingRule.GetTargetContainer(), + sidecarConfig: b.Sidecars, + }); err != nil { + return fmt.Errorf("validate HTTP load balancer target: %w", err) + } + if b.ServiceConnectEnabled() { + if b.RoutingRule.GetTargetContainer() == nil && b.ImageConfig.Port == nil { + return fmt.Errorf(`cannot enable "network.connect" when no port exposed`) + } + } if err = validateContainerDeps(validateDependenciesOpts{ sidecarConfig: b.Sidecars, imageConfig: b.ImageConfig.Image, @@ -1245,6 +1260,19 @@ func (n NetworkConfig) validate() error { if err := n.VPC.validate(); err != nil { return fmt.Errorf(`validate "vpc": %w`, err) } + if err := n.Connect.validate(); err != nil { + return fmt.Errorf(`validate "connect": %w`, err) + } + return nil +} + +// validate returns nil if ServiceConnectBoolOrArgs is configured correctly. +func (s ServiceConnectBoolOrArgs) validate() error { + return s.ServiceConnectArgs.validate() +} + +// validate is a no-op for ServiceConnectArgs. +func (ServiceConnectArgs) validate() error { return nil } @@ -1590,6 +1618,7 @@ type containerDependency struct { type validateTargetContainerOpts struct { mainContainerName string + mainContainerPort *uint16 targetContainer *string sidecarConfig map[string]*SidecarConfig } @@ -1609,14 +1638,17 @@ func validateTargetContainer(opts validateTargetContainerOpts) error { } targetContainer := aws.StringValue(opts.targetContainer) if targetContainer == opts.mainContainerName { + if opts.mainContainerPort == nil { + return fmt.Errorf("target container %q doesn't expose a port", targetContainer) + } return nil } sidecar, ok := opts.sidecarConfig[targetContainer] if !ok { - return fmt.Errorf("target container %s doesn't exist", targetContainer) + return fmt.Errorf("target container %q doesn't exist", targetContainer) } if sidecar.Port == nil { - return fmt.Errorf("target container %s doesn't expose any port", targetContainer) + return fmt.Errorf("target container %q doesn't expose a port", targetContainer) } return nil } diff --git a/internal/pkg/manifest/validate_test.go b/internal/pkg/manifest/validate_test.go index 7a123d4f2b1..d1d0515f7d3 100644 --- a/internal/pkg/manifest/validate_test.go +++ b/internal/pkg/manifest/validate_test.go @@ -82,7 +82,7 @@ func TestLoadBalancedWebService_validate(t *testing.T) { LoadBalancedWebServiceConfig: LoadBalancedWebServiceConfig{ ImageConfig: testImageConfig, Network: NetworkConfig{ - vpcConfig{ + VPC: vpcConfig{ Placement: PlacementArgOrString{ PlacementString: (*PlacementString)(aws.String("")), }, @@ -409,7 +409,7 @@ func TestBackendService_validate(t *testing.T) { BackendServiceConfig: BackendServiceConfig{ ImageConfig: testImageConfig, Network: NetworkConfig{ - vpcConfig{ + VPC: vpcConfig{ Placement: PlacementArgOrString{ PlacementString: (*PlacementString)(aws.String("")), }, @@ -580,6 +580,37 @@ func TestBackendService_validate(t *testing.T) { }, wantedErrorMsgPrefix: `validate "publish": `, }, + "error if target container not found": { + config: BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: testImageConfig, + RoutingRule: RoutingRuleConfiguration{ + TargetContainer: aws.String("api"), + Path: aws.String("/"), + }, + }, + Workload: Workload{ + Name: aws.String("api"), + }, + }, + wantedError: fmt.Errorf(`validate HTTP load balancer target: target container "api" doesn't expose a port`), + }, + "error if service connect is enabled without any port exposed": { + config: BackendService{ + BackendServiceConfig: BackendServiceConfig{ + ImageConfig: testImageConfig, + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{ + EnableServiceConnect: aws.Bool(true), + }, + }, + }, + Workload: Workload{ + Name: aws.String("api"), + }, + }, + wantedError: fmt.Errorf(`cannot enable "network.connect" when no port exposed`), + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { @@ -786,7 +817,7 @@ func TestWorkerService_validate(t *testing.T) { WorkerServiceConfig: WorkerServiceConfig{ ImageConfig: testImageConfig, Network: NetworkConfig{ - vpcConfig{ + VPC: vpcConfig{ Placement: PlacementArgOrString{ PlacementString: (*PlacementString)(aws.String("")), }, @@ -992,7 +1023,7 @@ func TestScheduledJob_validate(t *testing.T) { ScheduledJobConfig: ScheduledJobConfig{ ImageConfig: testImageConfig, Network: NetworkConfig{ - vpcConfig{ + VPC: vpcConfig{ Placement: PlacementArgOrString{ PlacementString: (*PlacementString)(aws.String("")), }, @@ -3033,9 +3064,9 @@ func TestValidateLoadBalancerTarget(t *testing.T) { mainContainerName: "mockMainContainer", targetContainer: aws.String("foo"), }, - wanted: fmt.Errorf("target container foo doesn't exist"), + wanted: fmt.Errorf(`target container "foo" doesn't exist`), }, - "should return an error if target container doesn't expose any port": { + "should return an error if target container doesn't expose a port": { in: validateTargetContainerOpts{ mainContainerName: "mockMainContainer", targetContainer: aws.String("foo"), @@ -3043,7 +3074,7 @@ func TestValidateLoadBalancerTarget(t *testing.T) { "foo": {}, }, }, - wanted: fmt.Errorf("target container foo doesn't expose any port"), + wanted: fmt.Errorf(`target container "foo" doesn't expose a port`), }, "success with no target container set": { in: validateTargetContainerOpts{ diff --git a/internal/pkg/manifest/workload.go b/internal/pkg/manifest/workload.go index 2767a3a9066..8a02e0184b8 100644 --- a/internal/pkg/manifest/workload.go +++ b/internal/pkg/manifest/workload.go @@ -47,13 +47,14 @@ var ( var ( ErrAppRunnerInvalidPlatformWindows = errors.New("Windows is not supported for App Runner services") - errUnmarshalBuildOpts = errors.New("unable to unmarshal build field into string or compose-style map") - errUnmarshalPlatformOpts = errors.New("unable to unmarshal platform field into string or compose-style map") - errUnmarshalSecurityGroupOpts = errors.New(`unable to unmarshal "security_groups" field into slice of strings or compose-style map`) - errUnmarshalPlacementOpts = errors.New("unable to unmarshal placement field into string or compose-style map") - errUnmarshalSubnetsOpts = errors.New("unable to unmarshal subnets field into string slice or compose-style map") - errUnmarshalCountOpts = errors.New(`unable to unmarshal "count" field to an integer or autoscaling configuration`) - errUnmarshalRangeOpts = errors.New(`unable to unmarshal "range" field`) + errUnmarshalBuildOpts = errors.New("unable to unmarshal build field into string or compose-style map") + errUnmarshalPlatformOpts = errors.New("unable to unmarshal platform field into string or compose-style map") + errUnmarshalSecurityGroupOpts = errors.New(`unable to unmarshal "security_groups" field into slice of strings or compose-style map`) + errUnmarshalPlacementOpts = errors.New("unable to unmarshal placement field into string or compose-style map") + errUnmarshalServiceConnectOpts = errors.New(`unable to unmarshal "connect" field into boolean or compose-style map`) + errUnmarshalSubnetsOpts = errors.New("unable to unmarshal subnets field into string slice or compose-style map") + errUnmarshalCountOpts = errors.New(`unable to unmarshal "count" field to an integer or autoscaling configuration`) + errUnmarshalRangeOpts = errors.New(`unable to unmarshal "range" field`) errUnmarshalExec = errors.New(`unable to unmarshal "exec" field into boolean or exec configuration`) errUnmarshalEntryPoint = errors.New(`unable to unmarshal "entrypoint" into string or slice of strings`) @@ -458,7 +459,8 @@ func (t *FIFOTopicAdvanceConfigOrBool) UnmarshalYAML(value *yaml.Node) error { // NetworkConfig represents options for network connection to AWS resources within a VPC. type NetworkConfig struct { - VPC vpcConfig `yaml:"vpc"` + VPC vpcConfig `yaml:"vpc"` + Connect ServiceConnectBoolOrArgs `yaml:"connect"` } // IsEmpty returns empty if the struct has all zero members. @@ -473,6 +475,41 @@ func (c *NetworkConfig) requiredEnvFeatures() []string { return nil } +// ServiceConnectBoolOrArgs represents ECS Service Connect configuration. +type ServiceConnectBoolOrArgs struct { + EnableServiceConnect *bool + ServiceConnectArgs +} + +// UnmarshalYAML overrides the default YAML unmarshaling logic for the ServiceConnect +// struct, allowing it to perform more complex unmarshaling behavior. +// This method implements the yaml.Unmarshaler (v3) interface. +func (s *ServiceConnectBoolOrArgs) UnmarshalYAML(value *yaml.Node) error { + if err := value.Decode(&s.ServiceConnectArgs); err != nil { + var yamlTypeErr *yaml.TypeError + if !errors.As(err, &yamlTypeErr) { + return err + } + } + if !s.ServiceConnectArgs.isEmpty() { + s.EnableServiceConnect = nil + return nil + } + if err := value.Decode(&s.EnableServiceConnect); err != nil { + return errUnmarshalServiceConnectOpts + } + return nil +} + +// ServiceConnectArgs includes the advanced configuration for ECS Service Connect. +type ServiceConnectArgs struct { + Alias *string +} + +func (s *ServiceConnectArgs) isEmpty() bool { + return s.Alias == nil +} + // PlacementArgOrString represents where to place tasks. type PlacementArgOrString struct { *PlacementString diff --git a/internal/pkg/manifest/workload_test.go b/internal/pkg/manifest/workload_test.go index bf18c7eb5cd..9fa794ee71b 100644 --- a/internal/pkg/manifest/workload_test.go +++ b/internal/pkg/manifest/workload_test.go @@ -354,6 +354,49 @@ func TestPlatformArgsOrString_UnmarshalYAML(t *testing.T) { } } +func TestServiceConnect_UnmarshalYAML(t *testing.T) { + testCases := map[string]struct { + inContent []byte + + wantedStruct ServiceConnectBoolOrArgs + wantedError error + }{ + "returns error if both bool and args specified": { + inContent: []byte(`connect: true + alias: api`), + + wantedError: errors.New("yaml: line 2: mapping values are not allowed in this context"), + }, + "error if unmarshalable": { + inContent: []byte(`connect: + ohess: linus + archie: leg64`), + wantedError: errUnmarshalServiceConnectOpts, + }, + "success": { + inContent: []byte(`connect: + alias: api`), + wantedStruct: ServiceConnectBoolOrArgs{ + ServiceConnectArgs: ServiceConnectArgs{ + Alias: aws.String("api"), + }, + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + v := NetworkConfig{} + err := yaml.Unmarshal(tc.inContent, &v) + if tc.wantedError != nil { + require.EqualError(t, err, tc.wantedError.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedStruct, v.Connect) + } + }) + } +} + func TestPlacementArgOrString_UnmarshalYAML(t *testing.T) { testCases := map[string]struct { inContent []byte diff --git a/internal/pkg/template/templates/workloads/partials/cf/service-base-properties.yml b/internal/pkg/template/templates/workloads/partials/cf/service-base-properties.yml index 03e9a27ec1d..e066eaf68f6 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/service-base-properties.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/service-base-properties.yml @@ -33,10 +33,10 @@ CapacityProviderStrategy: {{- end}} {{- end}} {{- end }} -{{- if .ServiceConnect }} +{{- if and .ServiceConnect .SCFeatureFlag }} ServiceConnectConfiguration: Enabled: True - Namespace: {{.ServiceConnect.Namespace}} + Namespace: {{.ServiceDiscoveryEndpoint}} LogConfiguration: LogDriver: awslogs Options: @@ -44,7 +44,7 @@ ServiceConnectConfiguration: awslogs-group: !Ref LogGroup awslogs-stream-prefix: copilot Services: - - PortName: TargetPort + - PortName: target # Avoid using the same service with Service Discovery in a namespace. DiscoveryName: !Join ["-", [!Ref WorkloadName, "sc"]] ClientAliases: diff --git a/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml b/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml index 770df616c85..64691acb590 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml @@ -48,11 +48,10 @@ {{- if $sidecar.Port}} PortMappings: - ContainerPort: {{$sidecar.Port}} - {{- if $.ServiceConnect }} # remove when release - {{- if eq $.HTTPTargetContainer.Container $sidecar.Name}} - {{- if eq $.HTTPTargetContainer.Port $sidecar.Port}} - Name: TargetPort - {{- end}} + {{- if and $.ServiceConnect $.SCFeatureFlag}} # remove when release + {{/* Multiple exposed port is not supported yet. Therefore, if the target container is the sidecar, the target port must be the one and only one port that the container exposes. */}} + {{- if eq $.HTTPTargetContainer.Name $sidecar.Name}} + Name: target {{- end}} {{- end}} {{- if $sidecar.Protocol}} diff --git a/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml b/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml index 9845a28f0cb..e451cb152ad 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml @@ -29,9 +29,10 @@ {{- if eq .WorkloadType "Load Balanced Web Service"}} PortMappings: - ContainerPort: !Ref ContainerPort - {{- if .ServiceConnect }} # remove when release - {{- if eq .HTTPTargetContainer.Container .WorkloadName}} - Name: TargetPort + {{- if and .ServiceConnect .SCFeatureFlag}} # remove when release + {{/* Multiple exposed port is not supported yet. Therefore, if the target container is the sidecar, the target port must be the one and only one port that the container exposes. */}} + {{- if eq .HTTPTargetContainer.Name .WorkloadName}} + Name: target {{- end}} {{- end}} {{- if .NLB}} {{ $nlbListener := .NLB.Listener }} @@ -43,12 +44,14 @@ {{- end}} {{- end}} {{/* end if eq .WorkloadType "Load Balanced Web Service"*/}} {{- if eq .WorkloadType "Backend Service"}} -{{- if .ServiceConnect }} # remove when release - PortMappings: !If [ExposePort, [{ContainerPort: !Ref ContainerPort, Name: !Ref WorkloadName}], !Ref "AWS::NoValue"] +{{- if eq .HTTPTargetContainer.Name .WorkloadName}} +{{- if and .ServiceConnect .SCFeatureFlag}} # remove when release + PortMappings: !If [ExposePort, [{ContainerPort: !Ref ContainerPort, Name: target}], !Ref "AWS::NoValue"] {{- else}} PortMappings: !If [ExposePort, [{ContainerPort: !Ref ContainerPort}], !Ref "AWS::NoValue"] {{- end}} {{- end}} +{{- end}} {{- if .HealthCheck}} HealthCheck: Command: {{quoteSlice .HealthCheck.Command | fmtSlice}} diff --git a/internal/pkg/template/workload.go b/internal/pkg/template/workload.go index a5bd3f555cb..b10baf5933a 100644 --- a/internal/pkg/template/workload.go +++ b/internal/pkg/template/workload.go @@ -121,7 +121,7 @@ type WorkloadNestedStackOpts struct { // SidecarOpts holds configuration that's needed if the service has sidecar containers. type SidecarOpts struct { - Name *string + Name string Image *string Essential *bool Port *string @@ -210,8 +210,8 @@ type LogConfigOpts struct { // HTTPTargetContainer represents the target group of a load balancer that points to a container. type HTTPTargetContainer struct { - Container string - Port string // Port of the container. + Name string + Port string } // IsHTTPS returns true if the target container's port is 443. @@ -331,8 +331,7 @@ type NetworkLoadBalancer struct { // ServiceConnect holds configuration for ECS Service Connect. type ServiceConnect struct { - Namespace string - Alias *string + Alias *string } // AdvancedCount holds configuration for autoscaling and capacity provider @@ -597,6 +596,8 @@ type WorkloadOpts struct { // Additional options for worker service templates. Subscribe *SubscribeOpts + + SCFeatureFlag bool } // HealthCheckProtocol returns the protocol for the Load Balancer health check,