From 3313b1d4a73bc7871720b9936805361b0bc717f3 Mon Sep 17 00:00:00 2001 From: Penghao He Date: Mon, 28 Nov 2022 09:29:32 -0800 Subject: [PATCH] feat: add ECS service connect support (#4226) By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License. --- e2e/apprunner/back-end/main.go | 2 +- e2e/apprunner/front-end/main.go | 2 +- e2e/customized-env/customized_env_test.go | 8 +- e2e/exec/exec_test.go | 1 + e2e/init/init_test.go | 2 +- e2e/internal/client/outputs.go | 11 +- e2e/multi-env-app/multi_env_test.go | 8 +- e2e/multi-svc-app/back-end/main.go | 12 +- .../copilot/front-end/manifest.yml | 3 + e2e/multi-svc-app/front-end/main.go | 28 +- e2e/multi-svc-app/multi_svc_app_suite_test.go | 2 +- e2e/multi-svc-app/multi_svc_app_test.go | 29 +- e2e/multi-svc-app/www/main.go | 2 +- internal/pkg/aws/ecs/service.go | 37 ++ internal/pkg/aws/ecs/service_test.go | 85 ++++ internal/pkg/cli/svc_deploy.go | 2 + .../cloudformation/stack/backend_svc.go | 13 +- .../stack/backend_svc_integration_test.go | 1 - .../cloudformation/stack/backend_svc_test.go | 2 - .../lb_grpc_web_service_integration_test.go | 1 - ...lb_network_web_service_integration_test.go | 1 - .../stack/lb_web_service_integration_test.go | 1 - .../deploy/cloudformation/stack/lb_web_svc.go | 6 +- .../stack/testdata/stacklocal/override-cf.yml | 1 + .../backend/http-autoscaling-template.yml | 4 +- .../backend/http-full-config-template.yml | 4 +- .../backend/http-only-path-manifest.yml | 3 - .../backend/http-only-path-template.yml | 9 +- .../backend/https-path-alias-manifest.yml | 3 - .../backend/https-path-alias-template.yml | 4 +- .../workloads/backend/simple-manifest.yml | 2 + .../testdata/workloads/svc-grpc-manifest.yml | 3 - .../workloads/svc-grpc-test.stack.yml | 3 + .../stack/testdata/workloads/svc-manifest.yml | 2 - .../testdata/workloads/svc-nlb-dev.stack.yml | 3 + .../testdata/workloads/svc-nlb-manifest.yml | 2 - .../testdata/workloads/svc-nlb-prod.stack.yml | 3 + .../testdata/workloads/svc-staging.stack.yml | 3 + .../testdata/workloads/svc-test.stack.yml | 16 +- .../workloads/windows-svc-test.stack.yml | 3 + .../testdata/workloads/worker-manifest.yml | 2 + .../testdata/workloads/worker-test.stack.yml | 11 + .../deploy/cloudformation/stack/worker_svc.go | 5 + internal/pkg/describe/backend_service.go | 70 +-- internal/pkg/describe/backend_service_test.go | 421 ++++++++++-------- internal/pkg/describe/lb_web_service.go | 159 ++----- internal/pkg/describe/lb_web_service_test.go | 256 ++++++----- internal/pkg/describe/mocks/mock_service.go | 30 ++ internal/pkg/describe/service.go | 176 +++++++- internal/pkg/describe/service_test.go | 78 ++++ internal/pkg/describe/uri.go | 16 +- internal/pkg/describe/uri_test.go | 16 +- internal/pkg/describe/worker_service_test.go | 42 +- internal/pkg/ecs/ecs.go | 22 +- internal/pkg/ecs/ecs_test.go | 89 +++- internal/pkg/manifest/backend_svc.go | 17 - internal/pkg/manifest/backend_svc_test.go | 77 ---- internal/pkg/manifest/lb_web_svc.go | 5 - internal/pkg/manifest/lb_web_svc_test.go | 35 -- .../backend-svc-customhealthcheck.yml | 2 + internal/pkg/manifest/testdata/lb-svc.yml | 2 + internal/pkg/manifest/validate.go | 17 +- internal/pkg/manifest/validate_test.go | 28 +- internal/pkg/manifest/workload.go | 5 + internal/pkg/manifest/workload_test.go | 37 ++ .../partials/cf/service-base-properties.yml | 8 +- .../workloads/partials/cf/sidecars.yml | 2 - .../partials/cf/workload-container.yml | 6 - .../workloads/services/backend/manifest.yml | 5 +- .../workloads/services/lb-web/manifest.yml | 3 +- internal/pkg/template/workload.go | 12 +- mkdocs.yml | 3 +- regression/multi-svc-app/back-end/main.go | 4 +- .../multi-svc-app/back-end/swap/main.go | 4 +- regression/multi-svc-app/front-end/main.go | 2 +- .../multi-svc-app/front-end/swap/main.go | 2 +- regression/multi-svc-app/www/main.go | 2 +- regression/multi-svc-app/www/swap/main.go | 2 +- site/content/blogs/release-v124.en.md | 110 +++++ .../developing/environment-variables.en.md | 2 +- .../docs/developing/service-discovery.en.md | 41 -- .../developing/svc-to-svc-communication.en.md | 87 ++++ site/content/docs/include/network.en.md | 8 + .../docs/manifest/backend-service.en.md | 22 +- .../docs/manifest/rd-web-service.en.md | 11 +- 85 files changed, 1442 insertions(+), 839 deletions(-) create mode 100644 site/content/blogs/release-v124.en.md delete mode 100644 site/content/docs/developing/service-discovery.en.md create mode 100644 site/content/docs/developing/svc-to-svc-communication.en.md diff --git a/e2e/apprunner/back-end/main.go b/e2e/apprunner/back-end/main.go index 65571639e4e..9805d393a08 100644 --- a/e2e/apprunner/back-end/main.go +++ b/e2e/apprunner/back-end/main.go @@ -17,7 +17,7 @@ func HealthCheck(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) } -// ServiceDiscoveryGet just returns true no matter what +// ServiceDiscoveryGet just returns true no matter what. func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request) { log.Printf("Get on ServiceDiscovery endpoint Succeeded with message %s\n", message) w.WriteHeader(http.StatusOK) diff --git a/e2e/apprunner/front-end/main.go b/e2e/apprunner/front-end/main.go index 5d86bb48858..648f13a2b5b 100644 --- a/e2e/apprunner/front-end/main.go +++ b/e2e/apprunner/front-end/main.go @@ -29,7 +29,7 @@ const ( postgresDriver = "postgres" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/e2e/customized-env/customized_env_test.go b/e2e/customized-env/customized_env_test.go index f423b3b4ef2..75ea067b023 100644 --- a/e2e/customized-env/customized_env_test.go +++ b/e2e/customized-env/customized_env_test.go @@ -325,14 +325,14 @@ environments: } Expect(len(svc.ServiceDiscoveries)).To(Equal(3)) - var envs, namespaces, wantedNamespaces []string + var envs, endpoints, wantedEndpoints []string for _, sd := range svc.ServiceDiscoveries { envs = append(envs, sd.Environment[0]) - namespaces = append(namespaces, sd.Namespace) - wantedNamespaces = append(wantedNamespaces, fmt.Sprintf("%s.%s.%s.local:80", svc.SvcName, sd.Environment[0], appName)) + endpoints = append(endpoints, sd.Endpoint) + wantedEndpoints = append(wantedEndpoints, fmt.Sprintf("%s.%s.%s.local:80", svc.SvcName, sd.Environment[0], appName)) } Expect(envs).To(ConsistOf("test", "prod", "shared")) - Expect(namespaces).To(ConsistOf(wantedNamespaces)) + Expect(endpoints).To(ConsistOf(wantedEndpoints)) // Call each environment's endpoint and ensure it returns a 200 for _, env := range []string{"test", "prod", "shared"} { diff --git a/e2e/exec/exec_test.go b/e2e/exec/exec_test.go index 4e441a19dce..dd5a47b28fa 100644 --- a/e2e/exec/exec_test.go +++ b/e2e/exec/exec_test.go @@ -157,6 +157,7 @@ var _ = Describe("exec flow", func() { }) Expect(err).NotTo(HaveOccurred()) Expect(len(svc.Routes)).To(Equal(1)) + Expect(len(svc.ServiceConnects)).To(Equal(0)) route := svc.Routes[0] Expect(route.Environment).To(Equal(envName)) diff --git a/e2e/init/init_test.go b/e2e/init/init_test.go index 9de70eb1f94..3a662c4f200 100644 --- a/e2e/init/init_test.go +++ b/e2e/init/init_test.go @@ -132,7 +132,7 @@ var _ = Describe("init flow", func() { It("should return a valid service discovery namespace", func() { Expect(len(svc.ServiceDiscoveries)).To(Equal(1)) Expect(svc.ServiceDiscoveries[0].Environment).To(Equal([]string{"test"})) - Expect(svc.ServiceDiscoveries[0].Namespace).To(Equal(fmt.Sprintf("%s.%s.%s.local:80", svcName, envName, appName))) + Expect(svc.ServiceDiscoveries[0].Endpoint).To(Equal(fmt.Sprintf("%s.%s.%s.local:80", svcName, envName, appName))) }) It("should return the correct environment variables", func() { diff --git a/e2e/internal/client/outputs.go b/e2e/internal/client/outputs.go index 6951a48c129..d7019a0d7cb 100644 --- a/e2e/internal/client/outputs.go +++ b/e2e/internal/client/outputs.go @@ -65,7 +65,8 @@ type SvcShowOutput struct { Type string `json:"type"` AppName string `json:"application"` Configs []SvcShowConfigurations `json:"configurations"` - ServiceDiscoveries []SvcShowServiceDiscoveries `json:"serviceDiscovery"` + ServiceDiscoveries []SvcShowServiceEndpoints `json:"serviceDiscovery"` + ServiceConnects []SvcShowServiceEndpoints `json:"serviceConnect"` Routes []SvcShowRoutes `json:"routes"` Variables []SvcShowVariables `json:"variables"` Resources map[string][]*SvcShowResourceInfo `json:"resources"` @@ -86,10 +87,10 @@ type SvcShowRoutes struct { URL string `json:"url"` } -// SvcShowServiceDiscoveries contains serialized service discovery info for an service. -type SvcShowServiceDiscoveries struct { +// SvcShowServiceEndpoints contains serialized endpoint info for a service. +type SvcShowServiceEndpoints struct { Environment []string `json:"environment"` - Namespace string `json:"namespace"` + Endpoint string `json:"endpoint"` } // SvcShowVariables contains serialized environment variables for a service. @@ -127,7 +128,7 @@ func toSvcListOutput(jsonInput string) (*SvcListOutput, error) { return &output, json.Unmarshal([]byte(jsonInput), &output) } -//JobListOutput is the JSON output for job list. +// JobListOutput is the JSON output for job list. type JobListOutput struct { Jobs []WkldDescription `json:"jobs"` } diff --git a/e2e/multi-env-app/multi_env_test.go b/e2e/multi-env-app/multi_env_test.go index 2282e2cb90d..d6b33129ddf 100644 --- a/e2e/multi-env-app/multi_env_test.go +++ b/e2e/multi-env-app/multi_env_test.go @@ -218,14 +218,14 @@ var _ = Describe("Multiple Env App", func() { } Expect(len(svc.ServiceDiscoveries)).To(Equal(2)) - var envs, namespaces, wantedNamespaces []string + var envs, endpoints, wantedEndpoints []string for _, sd := range svc.ServiceDiscoveries { envs = append(envs, sd.Environment[0]) - namespaces = append(namespaces, sd.Namespace) - wantedNamespaces = append(wantedNamespaces, fmt.Sprintf("%s.%s.%s.local:80", svc.SvcName, sd.Environment[0], appName)) + endpoints = append(endpoints, sd.Endpoint) + wantedEndpoints = append(wantedEndpoints, fmt.Sprintf("%s.%s.%s.local:80", svc.SvcName, sd.Environment[0], appName)) } Expect(envs).To(ConsistOf("test", "prod")) - Expect(namespaces).To(ConsistOf(wantedNamespaces)) + Expect(endpoints).To(ConsistOf(wantedEndpoints)) // Call each environment's endpoint and ensure it returns a 200 for _, env := range []string{"test", "prod"} { diff --git a/e2e/multi-svc-app/back-end/main.go b/e2e/multi-svc-app/back-end/main.go index 95ca94caa81..de0c5fb5936 100644 --- a/e2e/multi-svc-app/back-end/main.go +++ b/e2e/multi-svc-app/back-end/main.go @@ -16,24 +16,24 @@ func HealthCheck(w http.ResponseWriter, req *http.Request, ps httprouter.Params) w.WriteHeader(http.StatusOK) } -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) w.Write([]byte("back-end")) } -// ServiceDiscoveryGet just returns true no matter what -func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - log.Println("Get on ServiceDiscovery endpoint Succeeded") +// Get just returns true no matter what. +func Get(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + log.Println("Get on service endpoint Succeeded") w.WriteHeader(http.StatusOK) - w.Write([]byte("back-end-service-discovery")) + w.Write([]byte("back-end-service")) } func main() { router := httprouter.New() router.GET("/back-end/", SimpleGet) - router.GET("/service-discovery/", ServiceDiscoveryGet) + router.GET("/service-endpoint/", Get) // Health Check router.GET("/", HealthCheck) diff --git a/e2e/multi-svc-app/copilot/front-end/manifest.yml b/e2e/multi-svc-app/copilot/front-end/manifest.yml index 59e225f337d..baf1e1d4547 100644 --- a/e2e/multi-svc-app/copilot/front-end/manifest.yml +++ b/e2e/multi-svc-app/copilot/front-end/manifest.yml @@ -22,6 +22,9 @@ http: # To match all requests you can use the "/" path. path: '/' +network: + connect: true + # Number of CPU units for the task. cpu: 256 # Amount of memory in MiB used by the task. diff --git a/e2e/multi-svc-app/front-end/main.go b/e2e/multi-svc-app/front-end/main.go index 09a34e4574d..ea70298dc3e 100644 --- a/e2e/multi-svc-app/front-end/main.go +++ b/e2e/multi-svc-app/front-end/main.go @@ -23,27 +23,35 @@ var ( volumeName = "efsTestVolume" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) w.Write([]byte("front-end")) } -// ServiceDiscoveryGet calls the back-end service, via service-discovery. +// ServiceGet calls the back-end service, via service-connect and service-discovery. // This call should succeed and return the value from the backend service. -// This test assumes the backend app is called "back-end". The 'service-discovery' endpoint -// of the back-end service is unreachable from the LB, so the only way to get it is -// through service discovery. The response should be `back-end-service-discovery` -func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - endpoint := fmt.Sprintf("http://back-end.%s/service-discovery/", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) - resp, err := http.Get(endpoint) +// This test assumes the backend app is called "back-end". The 'service-connect' and +// 'service-discovery' endpoint of the back-end service is unreachable from the LB, +// so the only way to get it is through service connect and service discovery. +// The response should be `back-end-service` +func ServiceGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + resp, err := http.Get("http://back-end/service-endpoint/") + if err != nil { + log.Printf("🚨 could call service connect endpoint: err=%s\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Println("Get on service connect endpoint Succeeded") + sdEndpoint := fmt.Sprintf("http://back-end.%s/service-endpoint/", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) + resp, err = http.Get(sdEndpoint) if err != nil { log.Printf("🚨 could call service discovery endpoint: err=%s\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - log.Println("Get on ServiceDiscovery endpoint Succeeded") + log.Println("Get on service discovery endpoint Succeeded") defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) w.WriteHeader(http.StatusOK) @@ -123,7 +131,7 @@ func PutEFSCheck(w http.ResponseWriter, req *http.Request, ps httprouter.Params) func main() { router := httprouter.New() router.GET("/", SimpleGet) - router.GET("/service-discovery-test", ServiceDiscoveryGet) + router.GET("/service-endpoint-test", ServiceGet) router.GET("/magicwords/", GetMagicWords) router.GET("/job-checker/", GetJobCheck) router.GET("/job-setter/", SetJobCheck) diff --git a/e2e/multi-svc-app/multi_svc_app_suite_test.go b/e2e/multi-svc-app/multi_svc_app_suite_test.go index 7db1b3199f5..c0be22da1de 100644 --- a/e2e/multi-svc-app/multi_svc_app_suite_test.go +++ b/e2e/multi-svc-app/multi_svc_app_suite_test.go @@ -17,7 +17,7 @@ var cli *client.CLI var aws *client.AWS var appName string -/** +/* The multi svc suite runs through several tests focusing on creating multiple services in one app. */ diff --git a/e2e/multi-svc-app/multi_svc_app_test.go b/e2e/multi-svc-app/multi_svc_app_test.go index ef47aee8411..4954b436d6a 100644 --- a/e2e/multi-svc-app/multi_svc_app_test.go +++ b/e2e/multi-svc-app/multi_svc_app_test.go @@ -199,6 +199,11 @@ var _ = Describe("Multiple Service App", func() { routeURL string ) BeforeAll(func() { + _, backEndDeployErr = cli.SvcDeploy(&client.SvcDeployInput{ + Name: "back-end", + EnvName: "test", + ImageTag: "gallopinggurdey", + }) _, frontEndDeployErr = cli.SvcDeploy(&client.SvcDeployInput{ Name: "front-end", EnvName: "test", @@ -214,11 +219,6 @@ var _ = Describe("Multiple Service App", func() { EnvName: "test", ImageTag: "gallopinggurdey", }) - _, backEndDeployErr = cli.SvcDeploy(&client.SvcDeployInput{ - Name: "back-end", - EnvName: "test", - ImageTag: "gallopinggurdey", - }) }) It("svc deploy should succeed", func() { @@ -301,15 +301,15 @@ var _ = Describe("Multiple Service App", func() { Expect(svcs["back-end"].Type).To(Equal("Backend Service")) }) - It("service discovery should be enabled and working", func() { + It("service internal endpoint should be enabled and working", func() { // The front-end service is set up to have a path called - // "/front-end/service-discovery-test" - this route + // "/front-end/service-endpoint-test" - this route // calls a function which makes a call via the service - // discovery endpoint, "back-end.local". If that back-end + // connect/discovery endpoint, "back-end.local". If that back-end // call succeeds, the back-end returns a response - // "back-end-service-discovery". This should be forwarded + // "back-end-service". This should be forwarded // back to us via the front-end api. - // [test] -- http req -> [front-end] -- service-discovery -> [back-end] + // [test] -- http req -> [front-end] -- service-connect -> [back-end] svcName := "front-end" svc, svcShowErr := cli.SvcShow(&client.SvcShowRequest{ AppName: appName, @@ -317,15 +317,17 @@ var _ = Describe("Multiple Service App", func() { }) Expect(svcShowErr).NotTo(HaveOccurred()) Expect(len(svc.Routes)).To(Equal(1)) + Expect(len(svc.ServiceConnects)).To(Equal(1)) + Expect(svc.ServiceConnects[0].Endpoint).To(Equal(fmt.Sprintf("%s:80", svcName))) - // Calls the front end's service discovery endpoint - which should connect + // Calls the front end's service connect/discovery endpoint - which should connect // to the backend, and pipe the backend response to us. route := svc.Routes[0] Expect(route.Environment).To(Equal("test")) routeURL = route.URL - resp, fetchErr := http.Get(fmt.Sprintf("%s/service-discovery-test/", route.URL)) + resp, fetchErr := http.Get(fmt.Sprintf("%s/service-endpoint-test/", route.URL)) Expect(fetchErr).NotTo(HaveOccurred()) Expect(resp.StatusCode).To(Equal(200)) @@ -333,8 +335,7 @@ var _ = Describe("Multiple Service App", func() { // name as the value. bodyBytes, err := ioutil.ReadAll(resp.Body) Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(Equal("back-end-service-discovery")) - + Expect(string(bodyBytes)).To(Equal("back-end-service")) }) It("should be able to write to EFS volume", func() { diff --git a/e2e/multi-svc-app/www/main.go b/e2e/multi-svc-app/www/main.go index cf90a599c90..63572211d7d 100644 --- a/e2e/multi-svc-app/www/main.go +++ b/e2e/multi-svc-app/www/main.go @@ -10,7 +10,7 @@ import ( "github.com/julienschmidt/httprouter" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/internal/pkg/aws/ecs/service.go b/internal/pkg/aws/ecs/service.go index 02d274c604d..616fbeb096e 100644 --- a/internal/pkg/aws/ecs/service.go +++ b/internal/pkg/aws/ecs/service.go @@ -70,6 +70,43 @@ func (s *Service) ServiceStatus() ServiceStatus { } } +// ServiceConnectAliases returns the ECS Service Connect client aliases for a service. +func (s *Service) ServiceConnectAliases() []string { + if len(s.Deployments) == 0 { + return nil + } + lastDeployment := s.Deployments[0] + scConfig := lastDeployment.ServiceConnectConfiguration + if scConfig == nil || !aws.BoolValue(scConfig.Enabled) { + return nil + } + var aliases []string + for _, service := range scConfig.Services { + defaultName := aws.StringValue(service.PortName) + if aws.StringValue(service.DiscoveryName) != "" { + defaultName = aws.StringValue(service.DiscoveryName) + } + defaultAlias := fmt.Sprintf("%s.%s", defaultName, aws.StringValue(scConfig.Namespace)) + if len(service.ClientAliases) == 0 { + aliases = append(aliases, defaultAlias) + continue + } + for _, clientAlias := range service.ClientAliases { + alias := defaultAlias + if aws.StringValue(clientAlias.DnsName) != "" { + alias = aws.StringValue(clientAlias.DnsName) + } + aliases = append(aliases, fmt.Sprintf("%s:%v", alias, aws.Int64Value(clientAlias.Port))) + } + } + return aliases +} + +// LastUpdatedAt returns the last updated time of the ECS service. +func (s *Service) LastUpdatedAt() time.Time { + return aws.TimeValue(s.Deployments[0].UpdatedAt) +} + // TargetGroups returns the ARNs of target groups attached to the service. func (s *Service) TargetGroups() []string { var targetGroupARNs []string diff --git a/internal/pkg/aws/ecs/service_test.go b/internal/pkg/aws/ecs/service_test.go index 6cabfa83627..8fb370d0109 100644 --- a/internal/pkg/aws/ecs/service_test.go +++ b/internal/pkg/aws/ecs/service_test.go @@ -93,3 +93,88 @@ func TestService_ServiceStatus(t *testing.T) { require.Equal(t, got, wanted) }) } + +func TestService_LastUpdatedAt(t *testing.T) { + mockTime1 := time.Unix(14945056, 0) + mockTime2 := time.Unix(14945059, 0) + t.Run("should return correct last updated value", func(t *testing.T) { + s := Service{ + Deployments: []*ecs.Deployment{ + { + UpdatedAt: &mockTime1, + }, + { + UpdatedAt: &mockTime2, + }, + }, + } + got := s.LastUpdatedAt() + require.Equal(t, mockTime1, got) + }) +} + +func TestService_ServiceConnectAliases(t *testing.T) { + tests := map[string]struct { + inService *Service + + wantedError error + wanted []string + }{ + "quit early if not enabled": { + inService: &Service{ + Deployments: []*ecs.Deployment{ + { + ServiceConnectConfiguration: &ecs.ServiceConnectConfiguration{ + Enabled: aws.Bool(false), + }, + }, + }, + }, + wanted: []string{}, + }, + "success": { + inService: &Service{ + Deployments: []*ecs.Deployment{ + { + ServiceConnectConfiguration: &ecs.ServiceConnectConfiguration{ + Enabled: aws.Bool(true), + Namespace: aws.String("foobar.local"), + Services: []*ecs.ServiceConnectService{ + { + PortName: aws.String("frontend"), + DiscoveryName: aws.String("front"), + }, + { + PortName: aws.String("frontend"), + }, + { + PortName: aws.String("frontend"), + ClientAliases: []*ecs.ServiceConnectClientAlias{ + { + Port: aws.Int64(5000), + }, + { + DnsName: aws.String("api"), + Port: aws.Int64(80), + }, + }, + }, + }, + }, + }, + }, + }, + wanted: []string{"front.foobar.local", "frontend.foobar.local", "frontend.foobar.local:5000", "api:80"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // WHEN + get := tc.inService.ServiceConnectAliases() + + // THEN + require.ElementsMatch(t, get, tc.wanted) + }) + } +} diff --git a/internal/pkg/cli/svc_deploy.go b/internal/pkg/cli/svc_deploy.go index f689bd7a639..95ed6e96666 100644 --- a/internal/pkg/cli/svc_deploy.go +++ b/internal/pkg/cli/svc_deploy.go @@ -455,6 +455,8 @@ func (o *deploySvcOpts) uriRecommendedActions() ([]string, error) { network = "from your internal network." case describe.URIAccessTypeServiceDiscovery: network = "with service discovery." + case describe.URIAccessTypeServiceConnect: + network = "with service connect." } return []string{ diff --git a/internal/pkg/deploy/cloudformation/stack/backend_svc.go b/internal/pkg/deploy/cloudformation/stack/backend_svc.go index a2da5bc51d4..30812415634 100644 --- a/internal/pkg/deploy/cloudformation/stack/backend_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/backend_svc.go @@ -16,11 +16,6 @@ import ( "github.com/aws/copilot-cli/internal/pkg/template/override" ) -const ( - // NoExposedContainerPort indicates no port should be exposed for the service container. - NoExposedContainerPort = "-1" -) - type backendSvcReadParser interface { template.ReadParser ParseBackendService(template.WorkloadOpts) (*template.Content, error) @@ -33,8 +28,7 @@ type BackendService struct { httpsEnabled bool albEnabled bool - parser backendSvcReadParser - SCFeatureFlag bool + parser backendSvcReadParser } // BackendServiceConfig contains data required to initialize a backend service stack. @@ -143,7 +137,7 @@ func (s *BackendService) Template() (string, error) { allowedSourceIPs = append(allowedSourceIPs, string(ipNet)) } var scConfig *template.ServiceConnect - if s.manifest.ServiceConnectEnabled() { + if s.manifest.Network.Connect.Enabled() { scConfig = convertServiceConnect(s.manifest.Network.Connect) } targetContainer, targetContainerPort := s.httpLoadBalancerTarget() @@ -196,7 +190,6 @@ 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) @@ -223,7 +216,7 @@ func (s *BackendService) httpLoadBalancerTarget() (targetContainer *string, targ } func (s *BackendService) containerPort() string { - port := NoExposedContainerPort + port := template.NoExposedContainerPort if s.manifest.BackendServiceConfig.ImageConfig.Port != nil { port = strconv.FormatUint(uint64(aws.Uint16Value(s.manifest.BackendServiceConfig.ImageConfig.Port)), 10) } diff --git a/internal/pkg/deploy/cloudformation/stack/backend_svc_integration_test.go b/internal/pkg/deploy/cloudformation/stack/backend_svc_integration_test.go index 7e961327361..f168d21ed64 100644 --- a/internal/pkg/deploy/cloudformation/stack/backend_svc_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/backend_svc_integration_test.go @@ -98,7 +98,6 @@ func TestBackendService_TemplateAndParamsGeneration(t *testing.T) { EnvVersion: "v1.42.0", }, }) - serializer.SCFeatureFlag = true require.NoError(t, err) // validate generated template diff --git a/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go b/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go index 2ba983138d8..ac2d607d27c 100644 --- a/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go +++ b/internal/pkg/deploy/cloudformation/stack/backend_svc_test.go @@ -283,7 +283,6 @@ Outputs: Key: "sha2/count.zip", }, }, - ServiceConnect: &template.ServiceConnect{}, ExecuteCommand: &template.ExecuteCommandOpts{}, NestedStack: &template.WorkloadNestedStackOpts{ StackName: addon.StackName, @@ -435,7 +434,6 @@ Outputs: Name: "envoy", Port: "443", }, - ServiceConnect: &template.ServiceConnect{}, HTTPHealthCheck: template.HTTPHealthCheckOpts{ HealthCheckPath: "/healthz", Port: "4200", diff --git a/internal/pkg/deploy/cloudformation/stack/lb_grpc_web_service_integration_test.go b/internal/pkg/deploy/cloudformation/stack/lb_grpc_web_service_integration_test.go index c89671bd91a..5fd8eec3728 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_grpc_web_service_integration_test.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_grpc_web_service_integration_test.go @@ -92,7 +92,6 @@ func TestGrpcLoadBalancedWebService_Template(t *testing.T) { EnvVersion: "v1.42.0", }, }) - 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_network_web_service_integration_test.go b/internal/pkg/deploy/cloudformation/stack/lb_network_web_service_integration_test.go index fc7ff82f195..23a664112ac 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 @@ -113,7 +113,6 @@ 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 d7f7c4509dd..81354ab305a 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 @@ -113,7 +113,6 @@ func TestLoadBalancedWebService_TemplateInteg(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 73bf9ed985a..a27f8a60f6b 100644 --- a/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/lb_web_svc.go @@ -38,8 +38,7 @@ type LoadBalancedWebService struct { publicSubnetCIDRBlocks []string appInfo deploy.AppInformation - parser loadBalancedWebSvcReadParser - SCFeatureFlag bool + parser loadBalancedWebSvcReadParser } // LoadBalancedWebServiceOption is used to configuring an optional field for LoadBalancedWebService. @@ -192,7 +191,7 @@ func (s *LoadBalancedWebService) Template() (string, error) { httpRedirect = aws.BoolValue(s.manifest.RoutingRule.RedirectToHTTPS) } var scConfig *template.ServiceConnect - if s.manifest.ServiceConnectEnabled() { + if s.manifest.Network.Connect.Enabled() { scConfig = convertServiceConnect(s.manifest.Network.Connect) } targetContainer, targetContainerPort := s.httpLoadBalancerTarget() @@ -248,7 +247,6 @@ 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/testdata/stacklocal/override-cf.yml b/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/override-cf.yml index 51f1fc5aa6c..e59ad1d9d0f 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/override-cf.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/stacklocal/override-cf.yml @@ -40,6 +40,7 @@ awslogs-stream-prefix: copilot PortMappings: - ContainerPort: !Ref ContainerPort + Name: target - ContainerPort: 2056 Protocol: "udp" Ulimits: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-template.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-template.yml index 59c3ecfdcdb..39a34a72e3c 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-template.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-autoscaling-template.yml @@ -101,7 +101,7 @@ Resources: PortMappings: !If [ ExposePort, - [{ ContainerPort: !Ref ContainerPort }], + [{ ContainerPort: !Ref ContainerPort, Name: target }], !Ref "AWS::NoValue", ] ExecutionRole: @@ -411,6 +411,8 @@ Resources: PropagateTags: SERVICE EnableExecuteCommand: true LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-template.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-template.yml index 085e4400893..671a1c362a1 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-template.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-full-config-template.yml @@ -101,7 +101,7 @@ Resources: PortMappings: !If [ ExposePort, - [{ ContainerPort: !Ref ContainerPort }], + [{ ContainerPort: !Ref ContainerPort, Name: target }], !Ref "AWS::NoValue", ] ExecutionRole: @@ -289,6 +289,8 @@ Resources: PropagateTags: SERVICE EnableExecuteCommand: true LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED 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 6a0fd564f78..677fb876bd7 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,9 +10,6 @@ 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/http-only-path-template.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-template.yml index afbf499ec62..6c5e8dc1861 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-template.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/http-only-path-template.yml @@ -98,12 +98,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" @@ -289,6 +284,8 @@ Resources: PropagateTags: SERVICE EnableExecuteCommand: true LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED 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 cb80c24ceef..91e06831d51 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,9 +10,6 @@ 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/https-path-alias-template.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-template.yml index cb998fbc996..6fd3672827c 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-template.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/https-path-alias-template.yml @@ -93,7 +93,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' @@ -390,6 +390,8 @@ Resources: PropagateTags: SERVICE EnableExecuteCommand: true LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-manifest.yml index 54419ac29a7..53fdabea06a 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/backend/simple-manifest.yml @@ -4,6 +4,8 @@ image: build: Dockerfile port: 8080 +network: + connect: true cpu: 256 memory: 512 count: 1 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 5478c9d5504..32ecf794a21 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,9 +37,6 @@ 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-grpc-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-test.stack.yml index c44942bbf64..bc41495d795 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-grpc-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: @@ -429,6 +430,8 @@ Resources: # If a bucket URL is specified, that means the template exists. MaximumPercent: 200 PropagateTags: SERVICE LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED 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 07ddad01086..085ece8d2fd 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-manifest.yml @@ -60,8 +60,6 @@ environments: path: / grace_period: 30s deregistration_delay: 30s - network: - connect: false prod: count: range: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-dev.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-dev.stack.yml index 637fd567d2c..ba3f6c791a6 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-dev.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-dev.stack.yml @@ -106,6 +106,7 @@ Resources: # If a bucket URL is specified, that means the template exists. awslogs-stream-prefix: copilot PortMappings: - ContainerPort: !Ref ContainerPort + Name: target - ContainerPort: 81 Protocol: tcp ExecutionRole: @@ -306,6 +307,8 @@ Resources: # If a bucket URL is specified, that means the template exists. MinimumHealthyPercent: 100 MaximumPercent: 200 PropagateTags: SERVICE + ServiceConnectConfiguration: + Enabled: False CapacityProviderStrategy: - CapacityProvider: FARGATE_SPOT Weight: 1 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 f6a11dd4f3a..a4bd097d0ce 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,8 +10,6 @@ image: build: ./Dockerfile port: 80 http: false -network: - connect: false nlb: port: 443/tls count: 5 diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-prod.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-prod.stack.yml index 657e7c89034..61f0505eac0 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-prod.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-nlb-prod.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 - Name: tls Image: 1234567890.dkr.ecr.us-west-2.amazonaws.com/proxy:cicdtest PortMappings: @@ -311,6 +312,8 @@ Resources: # If a bucket URL is specified, that means the template exists. MaximumPercent: 200 PropagateTags: SERVICE LaunchType: FARGATE + ServiceConnectConfiguration: + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-staging.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-staging.stack.yml index 298dfb28c99..e593adc29d6 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-staging.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/svc-staging.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: @@ -318,6 +319,8 @@ Resources: # If a bucket URL is specified, that means the template exists. MinimumHealthyPercent: 100 MaximumPercent: 200 PropagateTags: SERVICE + ServiceConnectConfiguration: + Enabled: False CapacityProviderStrategy: - CapacityProvider: FARGATE_SPOT Weight: 1 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 fd0fc9a4f43..75c7c458b24 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 @@ -432,21 +432,7 @@ Resources: # If a bucket URL is specified, that means the template exists. 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 + Enabled: False NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-test.stack.yml index 133ee190406..e66c4d178d4 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/windows-svc-test.stack.yml @@ -102,6 +102,7 @@ Resources: # If a bucket URL is specified, that means the template exists. awslogs-stream-prefix: copilot PortMappings: - ContainerPort: !Ref ContainerPort + Name: target ExecutionRole: Metadata: 'aws:copilot:description': 'An IAM Role for the Fargate agent to make AWS API calls on your behalf' @@ -298,6 +299,8 @@ Resources: # If a bucket URL is specified, that means the template exists. MinimumHealthyPercent: 100 MaximumPercent: 200 PropagateTags: SERVICE + ServiceConnectConfiguration: + Enabled: False LaunchType: FARGATE NetworkConfiguration: AwsvpcConfiguration: diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-manifest.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-manifest.yml index b0e87f81dba..f71c76519a8 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-manifest.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-manifest.yml @@ -85,4 +85,6 @@ environments: test: image: location: amazon/ecs-example + network: + connect: true diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-test.stack.yml index 466430eb305..a22d621e306 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/worker-test.stack.yml @@ -91,6 +91,8 @@ Metadata: test: image: location: amazon/ecs-example + network: + connect: true Parameters: AppName: Type: String @@ -621,6 +623,15 @@ Resources: - CapacityProvider: FARGATE Weight: 0 Base: 5 + 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 NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: ENABLED diff --git a/internal/pkg/deploy/cloudformation/stack/worker_svc.go b/internal/pkg/deploy/cloudformation/stack/worker_svc.go index f40a5f97311..af7469b948b 100644 --- a/internal/pkg/deploy/cloudformation/stack/worker_svc.go +++ b/internal/pkg/deploy/cloudformation/stack/worker_svc.go @@ -112,6 +112,10 @@ func (s *WorkerService) Template() (string, error) { if err != nil { return "", fmt.Errorf(`convert "publish" field for service %s: %w`, s.name, err) } + var scConfig *template.ServiceConnect + if s.manifest.Network.Connect.Enabled() { + scConfig = convertServiceConnect(s.manifest.Network.Connect) + } content, err := s.parser.ParseWorkerService(template.WorkloadOpts{ AppName: s.app, EnvName: s.env, @@ -137,6 +141,7 @@ func (s *WorkerService) Template() (string, error) { Network: convertNetworkConfig(s.manifest.Network), DeploymentConfiguration: convertDeploymentConfig(s.manifest.DeployConfig), EntryPoint: entrypoint, + ServiceConnect: scConfig, Command: command, DependsOn: convertDependsOn(s.manifest.ImageConfig.Image.DependsOn), CredentialsParameter: aws.StringValue(s.manifest.ImageConfig.Image.Credentials), diff --git a/internal/pkg/describe/backend_service.go b/internal/pkg/describe/backend_service.go index 03efb0122f5..0cf84789cf2 100644 --- a/internal/pkg/describe/backend_service.go +++ b/internal/pkg/describe/backend_service.go @@ -13,6 +13,7 @@ import ( "github.com/aws/copilot-cli/internal/pkg/aws/elbv2" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" "github.com/aws/copilot-cli/internal/pkg/docker/dockerengine" + "github.com/aws/copilot-cli/internal/pkg/template" cfnstack "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/describe/stack" @@ -104,7 +105,8 @@ func (d *BackendServiceDescriber) Describe() (HumanJSONStringer, error) { var routes []*WebServiceRoute var configs []*ECSServiceConfig - var services []*ServiceDiscovery + sdEndpoints := make(serviceDiscoveries) + scEndpoints := make(serviceConnects) var envVars []*containerEnvVar var secrets []*secret for _, env := range environments { @@ -131,17 +133,14 @@ func (d *BackendServiceDescriber) Describe() (HumanJSONStringer, error) { return nil, err } port := blankContainerPort - if svcParams[cfnstack.WorkloadContainerPortParamKey] != cfnstack.NoExposedContainerPort { - endpoint, err := envDescr.ServiceDiscoveryEndpoint() - if err != nil { + if isReachableWithinVPC(svcParams) { + port = svcParams[cfnstack.WorkloadTargetPortParamKey] + if err := sdEndpoints.collectEndpoints(envDescr, d.svc, env, port); err != nil { + return nil, err + } + if err := scEndpoints.collectEndpoints(svcDescr, env); err != nil { return nil, err } - port = svcParams[cfnstack.WorkloadContainerPortParamKey] - services = appendServiceDiscovery(services, serviceDiscovery{ - Service: d.svc, - Port: port, - Endpoint: endpoint, - }, env) } containerPlatform, err := svcDescr.Platform() if err != nil { @@ -185,17 +184,20 @@ func (d *BackendServiceDescriber) Describe() (HumanJSONStringer, error) { } return &backendSvcDesc{ - Service: d.svc, - Type: manifest.BackendServiceType, - App: d.app, - Configurations: configs, - Routes: routes, - ServiceDiscovery: services, - Variables: envVars, - Secrets: secrets, - Resources: resources, + ecsSvcDesc: ecsSvcDesc{ + Service: d.svc, + Type: manifest.BackendServiceType, + App: d.app, + Configurations: configs, + Routes: routes, + ServiceDiscovery: sdEndpoints, + ServiceConnect: scEndpoints, + Variables: envVars, + Secrets: secrets, + Resources: resources, - environments: environments, + environments: environments, + }, }, nil } @@ -211,17 +213,7 @@ func (d *BackendServiceDescriber) Manifest(env string) ([]byte, error) { // backendSvcDesc contains serialized parameters for a backend service. type backendSvcDesc struct { - Service string `json:"service"` - Type string `json:"type"` - App string `json:"application"` - Configurations ecsConfigurations `json:"configurations"` - Routes []*WebServiceRoute `json:"routes"` - ServiceDiscovery serviceDiscoveries `json:"serviceDiscovery"` - Variables containerEnvVars `json:"variables"` - Secrets secrets `json:"secrets,omitempty"` - Resources deployedSvcResources `json:"resources,omitempty"` - - environments []string `json:"-"` + ecsSvcDesc } // JSONString returns the stringified backendService struct with json format. @@ -255,9 +247,15 @@ func (w *backendSvcDesc) HumanString() string { fmt.Fprintf(writer, " %s\t%s\n", route.Environment, route.URL) } } - fmt.Fprint(writer, color.Bold.Sprint("\nService Discovery\n\n")) - writer.Flush() - w.ServiceDiscovery.humanString(writer) + if len(w.ServiceConnect) > 0 || len(w.ServiceDiscovery) > 0 { + fmt.Fprint(writer, color.Bold.Sprint("\nInternal Service Endpoint\n\n")) + writer.Flush() + endpoints := serviceEndpoints{ + discoveries: w.ServiceDiscovery, + connects: w.ServiceConnect, + } + endpoints.humanString(writer) + } fmt.Fprint(writer, color.Bold.Sprint("\nVariables\n\n")) writer.Flush() w.Variables.humanString(writer) @@ -275,3 +273,7 @@ func (w *backendSvcDesc) HumanString() string { writer.Flush() return b.String() } + +func isReachableWithinVPC(params map[string]string) bool { + return params[cfnstack.WorkloadTargetPortParamKey] != template.NoExposedContainerPort +} diff --git a/internal/pkg/describe/backend_service_test.go b/internal/pkg/describe/backend_service_test.go index c9553708a77..a857399e23f 100644 --- a/internal/pkg/describe/backend_service_test.go +++ b/internal/pkg/describe/backend_service_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/aws/copilot-cli/internal/pkg/aws/ecs" - cfnstack "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/describe/mocks" "github.com/aws/copilot-cli/internal/pkg/describe/stack" @@ -59,32 +58,56 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "80", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskMemoryParamKey: "512", - cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTargetPortParamKey: "80", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTaskCPUParamKey: "256", }, nil), - m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("", errors.New("some error")), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), + m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("", mockErr), ) }, wantedError: fmt.Errorf("retrieve service URI: retrieve service discovery endpoint for environment test: some error"), }, + "return error if fail to retrieve service connect dns names": { + setupMocks: func(m lbWebSvcDescriberMocks) { + params := map[string]string{ + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", + } + gomock.InOrder( + m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), + m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), + m.ecsDescriber.EXPECT().Params().Return(params, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), + m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().Params().Return(params, nil), + m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, mockErr), + ) + }, + wantedError: fmt.Errorf("retrieve service connect DNS names: some error"), + }, "return error if fail to retrieve platform": { setupMocks: func(m lbWebSvcDescriberMocks) { params := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", } gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), - m.ecsDescriber.EXPECT().Platform().Return(nil, errors.New("some error")), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), + m.ecsDescriber.EXPECT().Platform().Return(nil, mockErr), ) }, wantedError: fmt.Errorf("retrieve platform: some error"), @@ -92,18 +115,20 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { "return error if fail to retrieve environment variables": { setupMocks: func(m lbWebSvcDescriberMocks) { params := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", } gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", Architecture: "X86_64", @@ -116,18 +141,20 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { "return error if fail to retrieve secrets": { setupMocks: func(m lbWebSvcDescriberMocks) { params := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "80", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTargetPortParamKey: "80", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", } gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", Architecture: "X86_64", @@ -148,30 +175,32 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { shouldOutputResources: true, setupMocks: func(m lbWebSvcDescriberMocks) { testParams := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", } prodParams := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "2", - cfnstack.WorkloadTaskCPUParamKey: "512", - cfnstack.WorkloadTaskMemoryParamKey: "1024", + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "2", + cfnstack.WorkloadTaskCPUParamKey: "512", + cfnstack.WorkloadTaskMemoryParamKey: "1024", } mockParams := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-1", - cfnstack.WorkloadTaskCountParamKey: "2", - cfnstack.WorkloadTaskCPUParamKey: "512", - cfnstack.WorkloadTaskMemoryParamKey: "1024", + cfnstack.WorkloadTargetPortParamKey: "-1", + cfnstack.WorkloadTaskCountParamKey: "2", + cfnstack.WorkloadTaskCPUParamKey: "512", + cfnstack.WorkloadTaskMemoryParamKey: "1024", } gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv, prodEnv, mockEnv}, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(testParams, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), m.ecsDescriber.EXPECT().Params().Return(testParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", Architecture: "X86_64", @@ -192,9 +221,11 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { }, nil), m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil), m.ecsDescriber.EXPECT().Params().Return(prodParams, nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("prod.phonetool.local", nil), m.ecsDescriber.EXPECT().Params().Return(prodParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("prod.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", Architecture: "ARM64", @@ -250,123 +281,120 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { ) }, wantedBackendSvc: &backendSvcDesc{ - Service: testSvc, - Type: "Backend Service", - App: testApp, - Configurations: []*ECSServiceConfig{ - { - ServiceConfig: &ServiceConfig{ - CPU: "256", - Environment: "test", - Memory: "512", - Platform: "LINUX/X86_64", - Port: "5000", + ecsSvcDesc: ecsSvcDesc{ + Service: testSvc, + Type: "Backend Service", + App: testApp, + Configurations: []*ECSServiceConfig{ + { + ServiceConfig: &ServiceConfig{ + CPU: "256", + Environment: "test", + Memory: "512", + Platform: "LINUX/X86_64", + Port: "5000", + }, + Tasks: "1", }, - Tasks: "1", - }, - { - ServiceConfig: &ServiceConfig{ - CPU: "512", - Environment: "prod", - Memory: "1024", - Platform: "LINUX/ARM64", - Port: "5000", + { + ServiceConfig: &ServiceConfig{ + CPU: "512", + Environment: "prod", + Memory: "1024", + Platform: "LINUX/ARM64", + Port: "5000", + }, + Tasks: "2", }, - Tasks: "2", - }, - { - ServiceConfig: &ServiceConfig{ - CPU: "512", - Environment: "mockEnv", - Memory: "1024", - Platform: "LINUX/X86_64", - Port: "-", + { + ServiceConfig: &ServiceConfig{ + CPU: "512", + Environment: "mockEnv", + Memory: "1024", + Platform: "LINUX/X86_64", + Port: "-", + }, + Tasks: "2", }, - Tasks: "2", - }, - }, - ServiceDiscovery: []*ServiceDiscovery{ - { - Environment: []string{"test"}, - Namespace: "jobs.test.phonetool.local:5000", }, - { - Environment: []string{"prod"}, - Namespace: "jobs.prod.phonetool.local:5000", + ServiceDiscovery: serviceDiscoveries{ + "jobs.test.phonetool.local:5000": []string{"test"}, + "jobs.prod.phonetool.local:5000": []string{"prod"}, }, - }, - Variables: []*containerEnvVar{ - { - envVar: &envVar{ - Environment: "test", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "test", + ServiceConnect: serviceConnects{}, + Variables: []*containerEnvVar{ + { + envVar: &envVar{ + Environment: "test", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "test", + }, + Container: "container", }, - Container: "container", - }, - { - envVar: &envVar{ - Environment: "prod", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "prod", + { + envVar: &envVar{ + Environment: "prod", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "prod", + }, + Container: "container", }, - Container: "container", - }, - { - envVar: &envVar{ - Environment: "mockEnv", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "mockEnv", + { + envVar: &envVar{ + Environment: "mockEnv", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "mockEnv", + }, + Container: "container", }, - Container: "container", - }, - }, - Secrets: []*secret{ - { - Name: "GITHUB_WEBHOOK_SECRET", - Container: "container", - Environment: "test", - ValueFrom: "GH_WEBHOOK_SECRET", - }, - { - Name: "SOME_OTHER_SECRET", - Container: "container", - Environment: "prod", - ValueFrom: "SHHHHHHHH", }, - }, - Resources: map[string][]*stack.Resource{ - "test": { + Secrets: []*secret{ { - Type: "AWS::EC2::SecurityGroupIngress", - PhysicalID: "ContainerSecurityGroupIngressFromPublicALB", + Name: "GITHUB_WEBHOOK_SECRET", + Container: "container", + Environment: "test", + ValueFrom: "GH_WEBHOOK_SECRET", }, - }, - "prod": { { - Type: "AWS::EC2::SecurityGroup", - PhysicalID: "sg-0758ed6b233743530", + Name: "SOME_OTHER_SECRET", + Container: "container", + Environment: "prod", + ValueFrom: "SHHHHHHHH", }, }, - "mockEnv": { - { - Type: "AWS::EC2::SecurityGroup", - PhysicalID: "sg-2337435300758ed6b", + Resources: map[string][]*stack.Resource{ + "test": { + { + Type: "AWS::EC2::SecurityGroupIngress", + PhysicalID: "ContainerSecurityGroupIngressFromPublicALB", + }, + }, + "prod": { + { + Type: "AWS::EC2::SecurityGroup", + PhysicalID: "sg-0758ed6b233743530", + }, + }, + "mockEnv": { + { + Type: "AWS::EC2::SecurityGroup", + PhysicalID: "sg-2337435300758ed6b", + }, }, }, + environments: []string{"test", "prod", "mockEnv"}, }, - environments: []string{"test", "prod", "mockEnv"}, }, }, "internal alb success http": { shouldOutputResources: true, setupMocks: func(m lbWebSvcDescriberMocks) { params := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", - cfnstack.WorkloadRulePathParamKey: "mySvc", + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadRulePathParamKey: "mySvc", } resources := []*describeStack.Resource{ { @@ -388,6 +416,7 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { m.lbDescriber.EXPECT().ListenerRuleHostHeaders("listenerRuleARN").Return([]string{"jobs.test.phonetool.internal"}, nil), m.ecsDescriber.EXPECT().Params().Return(params, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return([]string{"jobs"}, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", Architecture: "X86_64", @@ -410,66 +439,68 @@ func TestBackendServiceDescriber_Describe(t *testing.T) { ) }, wantedBackendSvc: &backendSvcDesc{ - Service: testSvc, - Type: "Backend Service", - App: testApp, - Configurations: []*ECSServiceConfig{ - { - ServiceConfig: &ServiceConfig{ - CPU: "256", - Environment: "test", - Memory: "512", - Platform: "LINUX/X86_64", - Port: "5000", + ecsSvcDesc: ecsSvcDesc{ + Service: testSvc, + Type: "Backend Service", + App: testApp, + Configurations: []*ECSServiceConfig{ + { + ServiceConfig: &ServiceConfig{ + CPU: "256", + Environment: "test", + Memory: "512", + Platform: "LINUX/X86_64", + Port: "5000", + }, + Tasks: "1", }, - Tasks: "1", }, - }, - Routes: []*WebServiceRoute{ - { - Environment: "test", - URL: "http://jobs.test.phonetool.internal/mySvc", - }, - }, - ServiceDiscovery: []*ServiceDiscovery{ - { - Environment: []string{"test"}, - Namespace: "jobs.test.phonetool.local:5000", - }, - }, - Variables: []*containerEnvVar{ - { - envVar: &envVar{ + Routes: []*WebServiceRoute{ + { Environment: "test", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "test", + URL: "http://jobs.test.phonetool.internal/mySvc", }, - Container: "container", }, - }, - Secrets: []*secret{ - { - Name: "GITHUB_WEBHOOK_SECRET", - Container: "container", - Environment: "test", - ValueFrom: "GH_WEBHOOK_SECRET", + ServiceDiscovery: serviceDiscoveries{ + "jobs.test.phonetool.local:5000": []string{"test"}, }, - }, - Resources: map[string][]*stack.Resource{ - "test": { + ServiceConnect: serviceConnects{ + "jobs": []string{"test"}, + }, + Variables: []*containerEnvVar{ { - Type: "AWS::ElasticLoadBalancingV2::TargetGroup", - LogicalID: svcStackResourceALBTargetGroupLogicalID, - PhysicalID: "targetGroupARN", + envVar: &envVar{ + Environment: "test", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "test", + }, + Container: "container", }, + }, + Secrets: []*secret{ { - Type: svcStackResourceListenerRuleResourceType, - LogicalID: svcStackResourceHTTPListenerRuleLogicalID, - PhysicalID: "listenerRuleARN", + Name: "GITHUB_WEBHOOK_SECRET", + Container: "container", + Environment: "test", + ValueFrom: "GH_WEBHOOK_SECRET", + }, + }, + Resources: map[string][]*stack.Resource{ + "test": { + { + Type: "AWS::ElasticLoadBalancingV2::TargetGroup", + LogicalID: svcStackResourceALBTargetGroupLogicalID, + PhysicalID: "targetGroupARN", + }, + { + Type: svcStackResourceListenerRuleResourceType, + LogicalID: svcStackResourceHTTPListenerRuleLogicalID, + PhysicalID: "listenerRuleARN", + }, }, }, + environments: []string{"test"}, }, - environments: []string{"test"}, }, }, } @@ -531,12 +562,12 @@ Configurations test 1 0.25 512 LINUX/X86_64 80 prod 3 0.5 1024 LINUX/ARM64 5000 -Service Discovery +Internal Service Endpoint - Environment Namespace - ----------- --------- - test http://my-svc.test.my-app.local:5000 - prod http://my-svc.prod.my-app.local:5000 + Endpoint Environment Type + -------- ----------- ---- + http://my-svc.prod.my-app.local:5000 prod Service Discovery + http://my-svc.test.my-app.local:5000 test Service Discovery Variables @@ -560,7 +591,7 @@ Resources prod AWS::EC2::SecurityGroupIngress ContainerSecurityGroupIngressFromPublicALB `, - wantedJSONString: "{\"service\":\"my-svc\",\"type\":\"Backend Service\",\"application\":\"my-app\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"256\",\"memory\":\"512\",\"platform\":\"LINUX/X86_64\",\"tasks\":\"1\"},{\"environment\":\"prod\",\"port\":\"5000\",\"cpu\":\"512\",\"memory\":\"1024\",\"platform\":\"LINUX/ARM64\",\"tasks\":\"3\"}],\"routes\":null,\"serviceDiscovery\":[{\"environment\":[\"test\"],\"namespace\":\"http://my-svc.test.my-app.local:5000\"},{\"environment\":[\"prod\"],\"namespace\":\"http://my-svc.prod.my-app.local:5000\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\",\"container\":\"container\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\",\"container\":\"container\"}],\"secrets\":[{\"name\":\"GITHUB_WEBHOOK_SECRET\",\"container\":\"container\",\"environment\":\"test\",\"valueFrom\":\"GH_WEBHOOK_SECRET\"},{\"name\":\"SOME_OTHER_SECRET\",\"container\":\"container\",\"environment\":\"prod\",\"valueFrom\":\"SHHHHH\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::EC2::SecurityGroupIngress\",\"physicalID\":\"ContainerSecurityGroupIngressFromPublicALB\"}],\"test\":[{\"type\":\"AWS::EC2::SecurityGroup\",\"physicalID\":\"sg-0758ed6b233743530\"}]}}\n", + wantedJSONString: "{\"service\":\"my-svc\",\"type\":\"Backend Service\",\"application\":\"my-app\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"256\",\"memory\":\"512\",\"platform\":\"LINUX/X86_64\",\"tasks\":\"1\"},{\"environment\":\"prod\",\"port\":\"5000\",\"cpu\":\"512\",\"memory\":\"1024\",\"platform\":\"LINUX/ARM64\",\"tasks\":\"3\"}],\"routes\":null,\"serviceDiscovery\":[{\"environment\":[\"prod\"],\"endpoint\":\"http://my-svc.prod.my-app.local:5000\"},{\"environment\":[\"test\"],\"endpoint\":\"http://my-svc.test.my-app.local:5000\"}],\"variables\":[{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\",\"container\":\"container\"},{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\",\"container\":\"container\"}],\"secrets\":[{\"name\":\"GITHUB_WEBHOOK_SECRET\",\"container\":\"container\",\"environment\":\"test\",\"valueFrom\":\"GH_WEBHOOK_SECRET\"},{\"name\":\"SOME_OTHER_SECRET\",\"container\":\"container\",\"environment\":\"prod\",\"valueFrom\":\"SHHHHH\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::EC2::SecurityGroupIngress\",\"physicalID\":\"ContainerSecurityGroupIngressFromPublicALB\"}],\"test\":[{\"type\":\"AWS::EC2::SecurityGroup\",\"physicalID\":\"sg-0758ed6b233743530\"}]}}\n", }, } @@ -620,15 +651,9 @@ Resources ValueFrom: "SHHHHH", }, } - sds := []*ServiceDiscovery{ - { - Environment: []string{"test"}, - Namespace: "http://my-svc.test.my-app.local:5000", - }, - { - Environment: []string{"prod"}, - Namespace: "http://my-svc.prod.my-app.local:5000", - }, + sds := serviceDiscoveries{ + "http://my-svc.test.my-app.local:5000": []string{"test"}, + "http://my-svc.prod.my-app.local:5000": []string{"prod"}, } resources := map[string][]*stack.Resource{ "test": { @@ -645,15 +670,17 @@ Resources }, } backendSvc := &backendSvcDesc{ - Service: "my-svc", - Type: "Backend Service", - Configurations: config, - App: "my-app", - Variables: envVars, - Secrets: secrets, - ServiceDiscovery: sds, - Resources: resources, - environments: []string{"test", "prod"}, + ecsSvcDesc: ecsSvcDesc{ + Service: "my-svc", + Type: "Backend Service", + Configurations: config, + App: "my-app", + Variables: envVars, + Secrets: secrets, + ServiceDiscovery: sds, + Resources: resources, + environments: []string{"test", "prod"}, + }, } human := backendSvc.HumanString() json, _ := backendSvc.JSONString() diff --git a/internal/pkg/describe/lb_web_service.go b/internal/pkg/describe/lb_web_service.go index bcf12fb2e11..081204545af 100644 --- a/internal/pkg/describe/lb_web_service.go +++ b/internal/pkg/describe/lb_web_service.go @@ -8,15 +8,12 @@ import ( "encoding/json" "errors" "fmt" - "io" - "sort" "strconv" "strings" "text/tabwriter" "github.com/aws/copilot-cli/internal/pkg/docker/dockerengine" - "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/copilot-cli/internal/pkg/aws/elbv2" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" @@ -129,7 +126,8 @@ func (d *LBWebServiceDescriber) Describe() (HumanJSONStringer, error) { var routes []*WebServiceRoute var configs []*ECSServiceConfig - var serviceDiscoveries []*ServiceDiscovery + svcDiscoveries := make(serviceDiscoveries) + svcConnects := make(serviceConnects) var envVars []*containerEnvVar var secrets []*secret for _, env := range environments { @@ -160,7 +158,7 @@ func (d *LBWebServiceDescriber) Describe() (HumanJSONStringer, error) { configs = append(configs, &ECSServiceConfig{ ServiceConfig: &ServiceConfig{ Environment: env, - Port: svcParams[cfnstack.WorkloadContainerPortParamKey], + Port: svcParams[cfnstack.WorkloadTargetPortParamKey], CPU: svcParams[cfnstack.WorkloadTaskCPUParamKey], Memory: svcParams[cfnstack.WorkloadTaskMemoryParamKey], Platform: dockerengine.PlatformString(containerPlatform.OperatingSystem, containerPlatform.Architecture), @@ -171,15 +169,13 @@ func (d *LBWebServiceDescriber) Describe() (HumanJSONStringer, error) { if err != nil { return nil, err } - endpoint, err := envDescr.ServiceDiscoveryEndpoint() - if err != nil { + if err := svcDiscoveries.collectEndpoints( + envDescr, d.svc, env, svcParams[cfnstack.WorkloadTargetPortParamKey]); err != nil { + return nil, err + } + if err := svcConnects.collectEndpoints(svcDescr, env); err != nil { return nil, err } - serviceDiscoveries = appendServiceDiscovery(serviceDiscoveries, serviceDiscovery{ - Service: d.svc, - Port: svcParams[cfnstack.WorkloadContainerPortParamKey], - Endpoint: endpoint, - }, env) envVars = append(envVars, flattenContainerEnvVars(env, webSvcEnvVars)...) webSvcSecrets, err := svcDescr.Secrets() if err != nil { @@ -203,17 +199,20 @@ func (d *LBWebServiceDescriber) Describe() (HumanJSONStringer, error) { } return &webSvcDesc{ - Service: d.svc, - Type: manifest.LoadBalancedWebServiceType, - App: d.app, - Configurations: configs, - Routes: routes, - ServiceDiscovery: serviceDiscoveries, - Variables: envVars, - Secrets: secrets, - Resources: resources, - - environments: environments, + ecsSvcDesc: ecsSvcDesc{ + Service: d.svc, + Type: manifest.LoadBalancedWebServiceType, + App: d.app, + Configurations: configs, + Routes: routes, + ServiceDiscovery: svcDiscoveries, + ServiceConnect: svcConnects, + Variables: envVars, + Secrets: secrets, + Resources: resources, + + environments: environments, + }, }, nil } @@ -227,98 +226,15 @@ func (d *LBWebServiceDescriber) Manifest(env string) ([]byte, error) { return cfn.Manifest() } -type secret struct { - Name string `json:"name"` - Container string `json:"container"` - Environment string `json:"environment"` - ValueFrom string `json:"valueFrom"` -} - -type secrets []*secret - -func (s secrets) humanString(w io.Writer) { - headers := []string{"Name", "Container", "Environment", "Value From"} - fmt.Fprintf(w, " %s\n", strings.Join(headers, "\t")) - fmt.Fprintf(w, " %s\n", strings.Join(underline(headers), "\t")) - sort.SliceStable(s, func(i, j int) bool { return s[i].Environment < s[j].Environment }) - sort.SliceStable(s, func(i, j int) bool { return s[i].Container < s[j].Container }) - sort.SliceStable(s, func(i, j int) bool { return s[i].Name < s[j].Name }) - if len(s) > 0 { - valueFrom := s[0].ValueFrom - if _, err := arn.Parse(s[0].ValueFrom); err != nil { - // If the valueFrom is not an ARN, preface it with "parameter/" - valueFrom = fmt.Sprintf("parameter/%s", s[0].ValueFrom) - } - fmt.Fprintf(w, " %s\n", strings.Join([]string{s[0].Name, s[0].Container, s[0].Environment, valueFrom}, "\t")) - } - for prev, cur := 0, 1; cur < len(s); prev, cur = prev+1, cur+1 { - valueFrom := s[cur].ValueFrom - if _, err := arn.Parse(s[cur].ValueFrom); err != nil { - // If the valueFrom is not an ARN, preface it with "parameter/" - valueFrom = fmt.Sprintf("parameter/%s", s[cur].ValueFrom) - } - cols := []string{s[cur].Name, s[cur].Container, s[cur].Environment, valueFrom} - if s[prev].Name == s[cur].Name { - cols[0] = dittoSymbol - } - if s[prev].Container == s[cur].Container { - cols[1] = dittoSymbol - } - if s[prev].Environment == s[cur].Environment { - cols[2] = dittoSymbol - } - if s[prev].ValueFrom == s[cur].ValueFrom { - cols[3] = dittoSymbol - } - fmt.Fprintf(w, " %s\n", strings.Join(cols, "\t")) - } -} - -func underline(headings []string) []string { - var lines []string - for _, heading := range headings { - line := strings.Repeat("-", len(heading)) - lines = append(lines, line) - } - return lines -} - // WebServiceRoute contains serialized route parameters for a web service. type WebServiceRoute struct { Environment string `json:"environment"` URL string `json:"url"` } -// ServiceDiscovery contains serialized service discovery info for an service. -type ServiceDiscovery struct { - Environment []string `json:"environment"` - Namespace string `json:"namespace"` -} - -type serviceDiscoveries []*ServiceDiscovery - -func (s serviceDiscoveries) humanString(w io.Writer) { - headers := []string{"Environment", "Namespace"} - fmt.Fprintf(w, " %s\n", strings.Join(headers, "\t")) - fmt.Fprintf(w, " %s\n", strings.Join(underline(headers), "\t")) - for _, sd := range s { - fmt.Fprintf(w, " %s\t%s\n", strings.Join(sd.Environment, ", "), sd.Namespace) - } -} - // webSvcDesc contains serialized parameters for a web service. type webSvcDesc struct { - Service string `json:"service"` - Type string `json:"type"` - App string `json:"application"` - Configurations ecsConfigurations `json:"configurations"` - Routes []*WebServiceRoute `json:"routes"` - ServiceDiscovery serviceDiscoveries `json:"serviceDiscovery"` - Variables containerEnvVars `json:"variables"` - Secrets secrets `json:"secrets,omitempty"` - Resources deployedSvcResources `json:"resources,omitempty"` - - environments []string + ecsSvcDesc } // JSONString returns the stringified webSvcDesc struct in json format. @@ -350,9 +266,15 @@ func (w *webSvcDesc) HumanString() string { for _, route := range w.Routes { fmt.Fprintf(writer, " %s\t%s\n", route.Environment, route.URL) } - fmt.Fprint(writer, color.Bold.Sprint("\nService Discovery\n\n")) - writer.Flush() - w.ServiceDiscovery.humanString(writer) + if len(w.ServiceConnect) > 0 || len(w.ServiceDiscovery) > 0 { + fmt.Fprint(writer, color.Bold.Sprint("\nInternal Service Endpoint\n\n")) + writer.Flush() + endpoints := serviceEndpoints{ + discoveries: w.ServiceDiscovery, + connects: w.ServiceConnect, + } + endpoints.humanString(writer) + } fmt.Fprint(writer, color.Bold.Sprint("\nVariables\n\n")) writer.Flush() w.Variables.humanString(writer) @@ -394,20 +316,3 @@ func IsStackNotExistsErr(err error) bool { } return true } - -func appendServiceDiscovery(sds []*ServiceDiscovery, sd serviceDiscovery, env string) []*ServiceDiscovery { - exist := false - for _, s := range sds { - if s.Namespace == sd.String() { - s.Environment = append(s.Environment, env) - exist = true - } - } - if !exist { - sds = append(sds, &ServiceDiscovery{ - Environment: []string{env}, - Namespace: sd.String(), - }) - } - return sds -} diff --git a/internal/pkg/describe/lb_web_service_test.go b/internal/pkg/describe/lb_web_service_test.go index 091fd475b5e..8708c4cccab 100644 --- a/internal/pkg/describe/lb_web_service_test.go +++ b/internal/pkg/describe/lb_web_service_test.go @@ -36,18 +36,18 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { prodSvcPath = "*" ) mockParams := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "80", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", - cfnstack.WorkloadRulePathParamKey: testSvcPath, + cfnstack.WorkloadTargetPortParamKey: "80", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadRulePathParamKey: testSvcPath, } mockProdParams := map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "5000", - cfnstack.WorkloadTaskCountParamKey: "2", - cfnstack.WorkloadTaskCPUParamKey: "512", - cfnstack.WorkloadTaskMemoryParamKey: "1024", - cfnstack.WorkloadRulePathParamKey: prodSvcPath, + cfnstack.WorkloadTargetPortParamKey: "5000", + cfnstack.WorkloadTaskCountParamKey: "2", + cfnstack.WorkloadTaskCPUParamKey: "512", + cfnstack.WorkloadTaskMemoryParamKey: "1024", + cfnstack.WorkloadRulePathParamKey: prodSvcPath, } mockErr := errors.New("some error") testCases := map[string]struct { @@ -179,6 +179,37 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { }, wantedError: fmt.Errorf("retrieve environment variables: some error"), }, + "return error if fail to retrieve service connect DNS names": { + setupMocks: func(m lbWebSvcDescriberMocks) { + gomock.InOrder( + m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), + m.ecsDescriber.EXPECT().ServiceStackResources().Return([]*stack.Resource{ + { + LogicalID: svcStackResourceALBTargetGroupLogicalID, + }, + }, nil), + m.ecsDescriber.EXPECT().Params().Return(mockParams, nil), + m.envDescriber.EXPECT().Outputs().Return(map[string]string{ + envOutputPublicLoadBalancerDNSName: testEnvLBDNSName, + }, nil), + m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ + OperatingSystem: "LINUX", + Architecture: "X86_64", + }, nil), + m.ecsDescriber.EXPECT().EnvVars().Return([]*ecs.ContainerEnvVar{ + { + Name: "COPILOT_ENVIRONMENT_NAME", + Container: "container", + Value: "prod", + }, + }, nil), + m.ecsDescriber.EXPECT().Params().Return(mockParams, nil), + m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, mockErr), + ) + }, + wantedError: fmt.Errorf("retrieve service connect DNS names: some error"), + }, "return error if fail to retrieve secrets": { setupMocks: func(m lbWebSvcDescriberMocks) { gomock.InOrder( @@ -205,6 +236,7 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { }, nil), m.ecsDescriber.EXPECT().Params().Return(mockParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Secrets().Return(nil, mockErr), ) }, @@ -237,6 +269,7 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { }, nil), m.ecsDescriber.EXPECT().Params().Return(mockParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil), m.ecsDescriber.EXPECT().Secrets().Return([]*ecs.ContainerSecret{ { Name: "GITHUB_WEBHOOK_SECRET", @@ -282,6 +315,7 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { }, nil), m.ecsDescriber.EXPECT().Params().Return(mockParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return([]string{testSvc}, nil), m.ecsDescriber.EXPECT().Secrets().Return([]*ecs.ContainerSecret{ { Name: "GITHUB_WEBHOOK_SECRET", @@ -312,6 +346,8 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { }, nil), m.ecsDescriber.EXPECT().Params().Return(mockProdParams, nil), m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("prod.phonetool.local", nil), + m.ecsDescriber.EXPECT().ServiceConnectDNSNames(). + Return([]string{testSvc}, nil), m.ecsDescriber.EXPECT().Secrets().Return([]*ecs.ContainerSecret{ { Name: "SOME_OTHER_SECRET", @@ -334,98 +370,97 @@ func TestLBWebServiceDescriber_Describe(t *testing.T) { ) }, wantedWebSvc: &webSvcDesc{ - Service: testSvc, - Type: "Load Balanced Web Service", - App: testApp, - Configurations: []*ECSServiceConfig{ - { - ServiceConfig: &ServiceConfig{ - CPU: "256", - Environment: "test", - Memory: "512", - Platform: "LINUX/X86_64", - Port: "80", + ecsSvcDesc: ecsSvcDesc{ + Service: testSvc, + Type: "Load Balanced Web Service", + App: testApp, + Configurations: []*ECSServiceConfig{ + { + ServiceConfig: &ServiceConfig{ + CPU: "256", + Environment: "test", + Memory: "512", + Platform: "LINUX/X86_64", + Port: "80", + }, + Tasks: "1", }, - Tasks: "1", - }, - { - ServiceConfig: &ServiceConfig{ - CPU: "512", - Environment: "prod", - Memory: "1024", - Platform: "LINUX/ARM64", - Port: "5000", + { + ServiceConfig: &ServiceConfig{ + CPU: "512", + Environment: "prod", + Memory: "1024", + Platform: "LINUX/ARM64", + Port: "5000", + }, + Tasks: "2", }, - Tasks: "2", - }, - }, - Routes: []*WebServiceRoute{ - { - Environment: "test", - URL: "http://abc.us-west-1.elb.amazonaws.com/*", - }, - { - Environment: "prod", - URL: "http://abc.us-west-1.elb.amazonaws.com/*", - }, - }, - ServiceDiscovery: []*ServiceDiscovery{ - { - Environment: []string{"test"}, - Namespace: "jobs.test.phonetool.local:80", }, - { - Environment: []string{"prod"}, - Namespace: "jobs.prod.phonetool.local:5000", - }, - }, - Variables: []*containerEnvVar{ - { - envVar: &envVar{ + Routes: []*WebServiceRoute{ + { Environment: "test", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "test", + URL: "http://abc.us-west-1.elb.amazonaws.com/*", }, - Container: "container1", - }, - { - envVar: &envVar{ + { Environment: "prod", - Name: "COPILOT_ENVIRONMENT_NAME", - Value: "prod", + URL: "http://abc.us-west-1.elb.amazonaws.com/*", }, - Container: "container2", }, - }, - Secrets: []*secret{ - { - Name: "GITHUB_WEBHOOK_SECRET", - Container: "container", - Environment: "test", - ValueFrom: "GH_WEBHOOK_SECRET", + ServiceDiscovery: serviceDiscoveries{ + "jobs.test.phonetool.local:80": []string{"test"}, + "jobs.prod.phonetool.local:5000": []string{"prod"}, }, - { - Name: "SOME_OTHER_SECRET", - Container: "container", - Environment: "prod", - ValueFrom: "SHHHHHHHH", + ServiceConnect: serviceConnects{ + testSvc: []string{"test", "prod"}, }, - }, - Resources: map[string][]*stack.Resource{ - "test": { + Variables: []*containerEnvVar{ { - Type: "AWS::EC2::SecurityGroupIngress", - PhysicalID: "ContainerSecurityGroupIngressFromPublicALB", + envVar: &envVar{ + Environment: "test", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "test", + }, + Container: "container1", + }, + { + envVar: &envVar{ + Environment: "prod", + Name: "COPILOT_ENVIRONMENT_NAME", + Value: "prod", + }, + Container: "container2", }, }, - "prod": { + Secrets: []*secret{ { - Type: "AWS::EC2::SecurityGroup", - PhysicalID: "sg-0758ed6b233743530", + Name: "GITHUB_WEBHOOK_SECRET", + Container: "container", + Environment: "test", + ValueFrom: "GH_WEBHOOK_SECRET", + }, + { + Name: "SOME_OTHER_SECRET", + Container: "container", + Environment: "prod", + ValueFrom: "SHHHHHHHH", }, }, + Resources: map[string][]*stack.Resource{ + "test": { + { + Type: "AWS::EC2::SecurityGroupIngress", + PhysicalID: "ContainerSecurityGroupIngressFromPublicALB", + }, + }, + "prod": { + { + Type: "AWS::EC2::SecurityGroup", + PhysicalID: "sg-0758ed6b233743530", + }, + }, + }, + environments: []string{"test", "prod"}, }, - environments: []string{"test", "prod"}, }, }, } @@ -495,12 +530,13 @@ Routes test http://my-pr-Publi.us-west-2.elb.amazonaws.com/frontend prod http://my-pr-Publi.us-west-2.elb.amazonaws.com/backend -Service Discovery +Internal Service Endpoint - Environment Namespace - ----------- --------- - test http://my-svc.test.my-app.local:5000 - prod http://my-svc.prod.my-app.local:5000 + Endpoint Environment Type + -------- ----------- ---- + my-svc test, prod Service Connect + http://my-svc.prod.my-app.local:5000 prod Service Discovery + http://my-svc.test.my-app.local:5000 test Service Discovery Variables @@ -525,7 +561,7 @@ Resources prod AWS::EC2::SecurityGroupIngress ContainerSecurityGroupIngressFromPublicALB `, - wantedJSONString: "{\"service\":\"my-svc\",\"type\":\"Load Balanced Web Service\",\"application\":\"my-app\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"256\",\"memory\":\"512\",\"platform\":\"LINUX/X86_64\",\"tasks\":\"1\"},{\"environment\":\"prod\",\"port\":\"5000\",\"cpu\":\"512\",\"memory\":\"1024\",\"platform\":\"LINUX/ARM64\",\"tasks\":\"3\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"http://my-pr-Publi.us-west-2.elb.amazonaws.com/frontend\"},{\"environment\":\"prod\",\"url\":\"http://my-pr-Publi.us-west-2.elb.amazonaws.com/backend\"}],\"serviceDiscovery\":[{\"environment\":[\"test\"],\"namespace\":\"http://my-svc.test.my-app.local:5000\"},{\"environment\":[\"prod\"],\"namespace\":\"http://my-svc.prod.my-app.local:5000\"}],\"variables\":[{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\",\"container\":\"containerA\"},{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\",\"container\":\"containerB\"},{\"environment\":\"prod\",\"name\":\"DIFFERENT_ENV_VAR\",\"value\":\"prod\",\"container\":\"containerB\"}],\"secrets\":[{\"name\":\"GITHUB_WEBHOOK_SECRET\",\"container\":\"containerA\",\"environment\":\"test\",\"valueFrom\":\"GH_WEBHOOK_SECRET\"},{\"name\":\"SOME_OTHER_SECRET\",\"container\":\"containerB\",\"environment\":\"prod\",\"valueFrom\":\"SHHHHH\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::EC2::SecurityGroupIngress\",\"physicalID\":\"ContainerSecurityGroupIngressFromPublicALB\"}],\"test\":[{\"type\":\"AWS::EC2::SecurityGroup\",\"physicalID\":\"sg-0758ed6b233743530\"}]}}\n", + wantedJSONString: "{\"service\":\"my-svc\",\"type\":\"Load Balanced Web Service\",\"application\":\"my-app\",\"configurations\":[{\"environment\":\"test\",\"port\":\"80\",\"cpu\":\"256\",\"memory\":\"512\",\"platform\":\"LINUX/X86_64\",\"tasks\":\"1\"},{\"environment\":\"prod\",\"port\":\"5000\",\"cpu\":\"512\",\"memory\":\"1024\",\"platform\":\"LINUX/ARM64\",\"tasks\":\"3\"}],\"routes\":[{\"environment\":\"test\",\"url\":\"http://my-pr-Publi.us-west-2.elb.amazonaws.com/frontend\"},{\"environment\":\"prod\",\"url\":\"http://my-pr-Publi.us-west-2.elb.amazonaws.com/backend\"}],\"serviceDiscovery\":[{\"environment\":[\"prod\"],\"endpoint\":\"http://my-svc.prod.my-app.local:5000\"},{\"environment\":[\"test\"],\"endpoint\":\"http://my-svc.test.my-app.local:5000\"}],\"serviceConnect\":[{\"environment\":[\"test\",\"prod\"],\"endpoint\":\"my-svc\"}],\"variables\":[{\"environment\":\"test\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"test\",\"container\":\"containerA\"},{\"environment\":\"prod\",\"name\":\"COPILOT_ENVIRONMENT_NAME\",\"value\":\"prod\",\"container\":\"containerB\"},{\"environment\":\"prod\",\"name\":\"DIFFERENT_ENV_VAR\",\"value\":\"prod\",\"container\":\"containerB\"}],\"secrets\":[{\"name\":\"GITHUB_WEBHOOK_SECRET\",\"container\":\"containerA\",\"environment\":\"test\",\"valueFrom\":\"GH_WEBHOOK_SECRET\"},{\"name\":\"SOME_OTHER_SECRET\",\"container\":\"containerB\",\"environment\":\"prod\",\"valueFrom\":\"SHHHHH\"}],\"resources\":{\"prod\":[{\"type\":\"AWS::EC2::SecurityGroupIngress\",\"physicalID\":\"ContainerSecurityGroupIngressFromPublicALB\"}],\"test\":[{\"type\":\"AWS::EC2::SecurityGroup\",\"physicalID\":\"sg-0758ed6b233743530\"}]}}\n", }, } @@ -603,15 +639,12 @@ Resources URL: "http://my-pr-Publi.us-west-2.elb.amazonaws.com/backend", }, } - sds := []*ServiceDiscovery{ - { - Environment: []string{"test"}, - Namespace: "http://my-svc.test.my-app.local:5000", - }, - { - Environment: []string{"prod"}, - Namespace: "http://my-svc.prod.my-app.local:5000", - }, + sds := serviceDiscoveries{ + "http://my-svc.test.my-app.local:5000": []string{"test"}, + "http://my-svc.prod.my-app.local:5000": []string{"prod"}, + } + scs := serviceConnects{ + "my-svc": []string{"test", "prod"}, } resources := map[string][]*stack.Resource{ "test": { @@ -628,16 +661,19 @@ Resources }, } webSvc := &webSvcDesc{ - Service: "my-svc", - Type: "Load Balanced Web Service", - Configurations: config, - App: "my-app", - Variables: envVars, - Secrets: secrets, - Routes: routes, - ServiceDiscovery: sds, - Resources: resources, - environments: []string{"test", "prod"}, + ecsSvcDesc: ecsSvcDesc{ + Service: "my-svc", + Type: "Load Balanced Web Service", + Configurations: config, + App: "my-app", + Variables: envVars, + Secrets: secrets, + Routes: routes, + ServiceDiscovery: sds, + ServiceConnect: scs, + Resources: resources, + environments: []string{"test", "prod"}, + }, } human := webSvc.HumanString() json, _ := webSvc.JSONString() diff --git a/internal/pkg/describe/mocks/mock_service.go b/internal/pkg/describe/mocks/mock_service.go index d0e6e6e6162..ea071e21c3e 100644 --- a/internal/pkg/describe/mocks/mock_service.go +++ b/internal/pkg/describe/mocks/mock_service.go @@ -203,6 +203,21 @@ func (m *MockecsClient) EXPECT() *MockecsClientMockRecorder { return m.recorder } +// Service mocks base method. +func (m *MockecsClient) Service(app, env, svc string) (*ecs.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Service", app, env, svc) + ret0, _ := ret[0].(*ecs.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Service indicates an expected call of Service. +func (mr *MockecsClientMockRecorder) Service(app, env, svc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Service", reflect.TypeOf((*MockecsClient)(nil).Service), app, env, svc) +} + // TaskDefinition mocks base method. func (m *MockecsClient) TaskDefinition(app, env, svc string) (*ecs.TaskDefinition, error) { m.ctrl.T.Helper() @@ -467,6 +482,21 @@ func (mr *MockecsDescriberMockRecorder) Secrets() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Secrets", reflect.TypeOf((*MockecsDescriber)(nil).Secrets)) } +// ServiceConnectDNSNames mocks base method. +func (m *MockecsDescriber) ServiceConnectDNSNames() ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ServiceConnectDNSNames") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ServiceConnectDNSNames indicates an expected call of ServiceConnectDNSNames. +func (mr *MockecsDescriberMockRecorder) ServiceConnectDNSNames() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ServiceConnectDNSNames", reflect.TypeOf((*MockecsDescriber)(nil).ServiceConnectDNSNames)) +} + // ServiceStackResources mocks base method. func (m *MockecsDescriber) ServiceStackResources() ([]*stack.Resource, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/describe/service.go b/internal/pkg/describe/service.go index dcc0240e7f5..2c672f7b9f0 100644 --- a/internal/pkg/describe/service.go +++ b/internal/pkg/describe/service.go @@ -4,6 +4,7 @@ package describe import ( + "encoding/json" "errors" "fmt" "io" @@ -11,6 +12,7 @@ import ( "sort" "strings" + "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/copilot-cli/internal/pkg/aws/apprunner" awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs" @@ -52,6 +54,7 @@ type DeployedEnvServicesLister interface { type ecsClient interface { TaskDefinition(app, env, svc string) (*awsecs.TaskDefinition, error) + Service(app, env, svc string) (*awsecs.Service, error) } type apprunnerClient interface { @@ -68,7 +71,7 @@ type workloadStackDescriber interface { type ecsDescriber interface { workloadStackDescriber - + ServiceConnectDNSNames() ([]string, error) Platform() (*awsecs.ContainerPlatform, error) EnvVars() ([]*awsecs.ContainerEnvVar, error) Secrets() ([]*awsecs.ContainerSecret, error) @@ -83,6 +86,21 @@ type apprunnerDescriber interface { IsPrivate() (bool, error) } +type ecsSvcDesc struct { + Service string `json:"service"` + Type string `json:"type"` + App string `json:"application"` + Configurations ecsConfigurations `json:"configurations"` + Routes []*WebServiceRoute `json:"routes"` + ServiceDiscovery serviceDiscoveries `json:"serviceDiscovery"` + ServiceConnect serviceConnects `json:"serviceConnect,omitempty"` + Variables containerEnvVars `json:"variables"` + Secrets secrets `json:"secrets,omitempty"` + Resources deployedSvcResources `json:"resources,omitempty"` + + environments []string `json:"-"` +} + // serviceStackDescriber provides base functionality for retrieving info about a service. type serviceStackDescriber struct { app string @@ -269,6 +287,15 @@ func (d *ecsServiceDescriber) Platform() (*awsecs.ContainerPlatform, error) { return platform, nil } +// ServiceConnectDNSNames returns the service connect dns names of a service. +func (d *ecsServiceDescriber) ServiceConnectDNSNames() ([]string, error) { + service, err := d.ecsClient.Service(d.app, d.env, d.service) + if err != nil { + return nil, fmt.Errorf("get service %s: %w", d.service, err) + } + return service.ServiceConnectAliases(), nil +} + // ServiceARN retrieves the ARN of the app runner service. func (d *appRunnerServiceDescriber) ServiceARN() (string, error) { serviceStackResources, err := d.ServiceStackResources() @@ -447,3 +474,150 @@ func (e containerEnvVars) humanString(w io.Writer) { printTable(w, headers, rows) } + +type secret struct { + Name string `json:"name"` + Container string `json:"container"` + Environment string `json:"environment"` + ValueFrom string `json:"valueFrom"` +} + +type secrets []*secret + +func (s secrets) humanString(w io.Writer) { + headers := []string{"Name", "Container", "Environment", "Value From"} + fmt.Fprintf(w, " %s\n", strings.Join(headers, "\t")) + fmt.Fprintf(w, " %s\n", strings.Join(underline(headers), "\t")) + sort.SliceStable(s, func(i, j int) bool { return s[i].Environment < s[j].Environment }) + sort.SliceStable(s, func(i, j int) bool { return s[i].Container < s[j].Container }) + sort.SliceStable(s, func(i, j int) bool { return s[i].Name < s[j].Name }) + if len(s) > 0 { + valueFrom := s[0].ValueFrom + if _, err := arn.Parse(s[0].ValueFrom); err != nil { + // If the valueFrom is not an ARN, preface it with "parameter/" + valueFrom = fmt.Sprintf("parameter/%s", s[0].ValueFrom) + } + fmt.Fprintf(w, " %s\n", strings.Join([]string{s[0].Name, s[0].Container, s[0].Environment, valueFrom}, "\t")) + } + for prev, cur := 0, 1; cur < len(s); prev, cur = prev+1, cur+1 { + valueFrom := s[cur].ValueFrom + if _, err := arn.Parse(s[cur].ValueFrom); err != nil { + // If the valueFrom is not an ARN, preface it with "parameter/" + valueFrom = fmt.Sprintf("parameter/%s", s[cur].ValueFrom) + } + cols := []string{s[cur].Name, s[cur].Container, s[cur].Environment, valueFrom} + if s[prev].Name == s[cur].Name { + cols[0] = dittoSymbol + } + if s[prev].Container == s[cur].Container { + cols[1] = dittoSymbol + } + if s[prev].Environment == s[cur].Environment { + cols[2] = dittoSymbol + } + if s[prev].ValueFrom == s[cur].ValueFrom { + cols[3] = dittoSymbol + } + fmt.Fprintf(w, " %s\n", strings.Join(cols, "\t")) + } +} + +func underline(headings []string) []string { + var lines []string + for _, heading := range headings { + line := strings.Repeat("-", len(heading)) + lines = append(lines, line) + } + return lines +} + +// endpointToEnvs is a mapping of endpoint to environments. +type endpointToEnvs map[string][]string + +func (e *endpointToEnvs) marshalJSON() ([]byte, error) { + type internalEndpoint struct { + Environment []string `json:"environment"` + Endpoint string `json:"endpoint"` + } + var internalEndpoints []internalEndpoint + for endpoint := range *e { + internalEndpoints = append(internalEndpoints, internalEndpoint{ + Environment: (*e)[endpoint], + Endpoint: endpoint, + }) + } + sort.Slice(internalEndpoints, func(i, j int) bool { return internalEndpoints[i].Endpoint < internalEndpoints[j].Endpoint }) + return json.Marshal(&internalEndpoints) +} + +func (e endpointToEnvs) add(endpoint string, env string) { + e[endpoint] = append(e[endpoint], env) +} + +type serviceDiscoveries endpointToEnvs + +// MarshalJSON overrides the default JSON marshaling logic for the serviceDiscoveries +// struct, allowing it to perform more complex marshaling behavior. +func (sds *serviceDiscoveries) MarshalJSON() ([]byte, error) { + return (*endpointToEnvs)(sds).marshalJSON() +} + +func (sds *serviceDiscoveries) collectEndpoints(descr envDescriber, svc, env, port string) error { + endpoint, err := descr.ServiceDiscoveryEndpoint() + if err != nil { + return err + } + sd := serviceDiscovery{ + Service: svc, + Port: port, + Endpoint: endpoint, + } + (*endpointToEnvs)(sds).add(sd.String(), env) + return nil +} + +type serviceConnects endpointToEnvs + +// MarshalJSON overrides the default JSON marshaling logic for the serviceConnects +// struct, allowing it to perform more complex marshaling behavior. +func (scs *serviceConnects) MarshalJSON() ([]byte, error) { + return (*endpointToEnvs)(scs).marshalJSON() +} + +func (scs *serviceConnects) collectEndpoints(descr ecsDescriber, env string) error { + scDNSNames, err := descr.ServiceConnectDNSNames() + if err != nil { + return fmt.Errorf("retrieve service connect DNS names: %w", err) + } + for _, dnsName := range scDNSNames { + (*endpointToEnvs)(scs).add(dnsName, env) + } + return nil +} + +type serviceEndpoints struct { + discoveries serviceDiscoveries + connects serviceConnects +} + +func (s serviceEndpoints) humanString(w io.Writer) { + headers := []string{"Endpoint", "Environment", "Type"} + fmt.Fprintf(w, " %s\n", strings.Join(headers, "\t")) + fmt.Fprintf(w, " %s\n", strings.Join(underline(headers), "\t")) + var scEndpoints []string + for endpoint := range s.connects { + scEndpoints = append(scEndpoints, endpoint) + } + sort.Slice(scEndpoints, func(i, j int) bool { return scEndpoints[i] < scEndpoints[j] }) + for _, endpoint := range scEndpoints { + fmt.Fprintf(w, " %s\t%s\t%s\n", endpoint, strings.Join(s.connects[endpoint], ", "), "Service Connect") + } + var sdEndpoints []string + for endpoint := range s.discoveries { + sdEndpoints = append(sdEndpoints, endpoint) + } + sort.Slice(sdEndpoints, func(i, j int) bool { return sdEndpoints[i] < sdEndpoints[j] }) + for _, endpoint := range sdEndpoints { + fmt.Fprintf(w, " %s\t%s\t%s\n", endpoint, strings.Join(s.discoveries[endpoint], ", "), "Service Discovery") + } +} diff --git a/internal/pkg/describe/service_test.go b/internal/pkg/describe/service_test.go index c0a475d4893..f8f5f56fc98 100644 --- a/internal/pkg/describe/service_test.go +++ b/internal/pkg/describe/service_test.go @@ -289,6 +289,84 @@ func TestServiceDescriber_EnvVars(t *testing.T) { } } +func TestServiceDescriber_ServiceConnectDNSNames(t *testing.T) { + const ( + testApp = "phonetool" + testSvc = "svc" + testEnv = "test" + ) + testCases := map[string]struct { + setupMocks func(mocks ecsSvcDescriberMocks) + + wantedDNSNames []string + wantedError error + }{ + "returns error if fails to get ECS service": { + setupMocks: func(m ecsSvcDescriberMocks) { + m.mockECSClient.EXPECT().Service(testApp, testEnv, testSvc).Return(nil, errors.New("some error")) + }, + + wantedError: errors.New("get service svc: some error"), + }, + "success": { + setupMocks: func(m ecsSvcDescriberMocks) { + m.mockECSClient.EXPECT().Service(testApp, testEnv, testSvc).Return(&awsecs.Service{ + Deployments: []*ecsapi.Deployment{ + { + ServiceConnectConfiguration: &ecsapi.ServiceConnectConfiguration{ + Enabled: aws.Bool(true), + Namespace: aws.String("foobar.com"), + Services: []*ecsapi.ServiceConnectService{ + { + PortName: aws.String("frontend"), + }, + }, + }, + }, + }, + }, nil) + }, + + wantedDNSNames: []string{"frontend.foobar.com"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockecsClient := mocks.NewMockecsClient(ctrl) + mocks := ecsSvcDescriberMocks{ + mockECSClient: mockecsClient, + } + + tc.setupMocks(mocks) + + d := &ecsServiceDescriber{ + serviceStackDescriber: &serviceStackDescriber{ + app: testApp, + service: testSvc, + env: testEnv, + }, + ecsClient: mockecsClient, + } + + // WHEN + actual, err := d.ServiceConnectDNSNames() + + // THEN + if tc.wantedError != nil { + require.EqualError(t, err, tc.wantedError.Error()) + } else { + require.NoError(t, err) + require.ElementsMatch(t, tc.wantedDNSNames, actual) + } + }) + } +} + func TestServiceDescriber_Secrets(t *testing.T) { const ( testApp = "phonetool" diff --git a/internal/pkg/describe/uri.go b/internal/pkg/describe/uri.go index 6195296c575..1b9101f676f 100644 --- a/internal/pkg/describe/uri.go +++ b/internal/pkg/describe/uri.go @@ -20,6 +20,7 @@ const ( URIAccessTypeInternet URIAccessTypeInternal URIAccessTypeServiceDiscovery + URIAccessTypeServiceConnect ) var ( @@ -192,20 +193,29 @@ func (d *BackendServiceDescriber) URI(envName string) (URI, error) { if err != nil { return URI{}, fmt.Errorf("get stack parameters for environment %s: %w", envName, err) } - port := svcStackParams[stack.WorkloadContainerPortParamKey] - if port == stack.NoExposedContainerPort { + if !isReachableWithinVPC(svcStackParams) { return URI{ URI: BlankServiceDiscoveryURI, AccessType: URIAccessTypeNone, }, nil } + scDNSNames, err := svcDescr.ServiceConnectDNSNames() + if err != nil { + return URI{}, fmt.Errorf("retrieve service connect DNS names: %w", err) + } + if len(scDNSNames) > 0 { + return URI{ + URI: english.OxfordWordSeries(scDNSNames, "or"), + AccessType: URIAccessTypeServiceConnect, + }, nil + } endpoint, err := envDescr.ServiceDiscoveryEndpoint() if err != nil { return URI{}, fmt.Errorf("retrieve service discovery endpoint for environment %s: %w", envName, err) } s := serviceDiscovery{ Service: d.svc, - Port: port, + Port: svcStackParams[stack.WorkloadTargetPortParamKey], Endpoint: endpoint, } return URI{ diff --git a/internal/pkg/describe/uri_test.go b/internal/pkg/describe/uri_test.go index cc98c9cf9c2..ea19dbd54c8 100644 --- a/internal/pkg/describe/uri_test.go +++ b/internal/pkg/describe/uri_test.go @@ -10,6 +10,7 @@ import ( "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/describe/mocks" + "github.com/aws/copilot-cli/internal/pkg/template" describeStack "github.com/aws/copilot-cli/internal/pkg/describe/stack" "github.com/golang/mock/gomock" @@ -372,17 +373,28 @@ func TestBackendServiceDescriber_URI(t *testing.T) { setupMocks: func(m lbWebSvcDescriberMocks) { m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil) m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - stack.WorkloadContainerPortParamKey: stack.NoExposedContainerPort, // No port is set for the backend service. + stack.WorkloadTargetPortParamKey: template.NoExposedContainerPort, // No port is set for the backend service. }, nil) }, wantedURI: BlankServiceDiscoveryURI, }, + "should return service connect endpoint if port is exposed": { + setupMocks: func(m lbWebSvcDescriberMocks) { + m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil) + m.ecsDescriber.EXPECT().Params().Return(map[string]string{ + stack.WorkloadTargetPortParamKey: "8080", + }, nil) + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return([]string{"my-svc:8080"}, nil) + }, + wantedURI: "my-svc:8080", + }, "should return service discovery endpoint if port is exposed": { setupMocks: func(m lbWebSvcDescriberMocks) { m.ecsDescriber.EXPECT().ServiceStackResources().Return(nil, nil) m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - stack.WorkloadContainerPortParamKey: "8080", + stack.WorkloadTargetPortParamKey: "8080", }, nil) + m.ecsDescriber.EXPECT().ServiceConnectDNSNames().Return(nil, nil) m.envDescriber.EXPECT().ServiceDiscoveryEndpoint().Return("test.app.local", nil) }, wantedURI: "my-svc.test.app.local:8080", diff --git a/internal/pkg/describe/worker_service_test.go b/internal/pkg/describe/worker_service_test.go index 2aa67ba8537..bb9d02f5d6e 100644 --- a/internal/pkg/describe/worker_service_test.go +++ b/internal/pkg/describe/worker_service_test.go @@ -56,10 +56,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", }, nil), m.ecsDescriber.EXPECT().Platform().Return(nil, errors.New("some error")), ) @@ -71,10 +70,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { gomock.InOrder( m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskMemoryParamKey: "512", - cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTaskCPUParamKey: "256", }, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", @@ -91,10 +89,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv}, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", }, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", @@ -119,10 +116,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { m.storeSvc.EXPECT().ListEnvironmentsDeployedTo(testApp, testSvc).Return([]string{testEnv, prodEnv, mockEnv}, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "1", - cfnstack.WorkloadTaskCPUParamKey: "256", - cfnstack.WorkloadTaskMemoryParamKey: "512", + cfnstack.WorkloadTaskCountParamKey: "1", + cfnstack.WorkloadTaskCPUParamKey: "256", + cfnstack.WorkloadTaskMemoryParamKey: "512", }, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", @@ -143,10 +139,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { }, }, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "2", - cfnstack.WorkloadTaskCPUParamKey: "512", - cfnstack.WorkloadTaskMemoryParamKey: "1024", + cfnstack.WorkloadTaskCountParamKey: "2", + cfnstack.WorkloadTaskCPUParamKey: "512", + cfnstack.WorkloadTaskMemoryParamKey: "1024", }, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", @@ -167,10 +162,9 @@ func TestWorkerServiceDescriber_Describe(t *testing.T) { }, }, nil), m.ecsDescriber.EXPECT().Params().Return(map[string]string{ - cfnstack.WorkloadContainerPortParamKey: "-", - cfnstack.WorkloadTaskCountParamKey: "2", - cfnstack.WorkloadTaskCPUParamKey: "512", - cfnstack.WorkloadTaskMemoryParamKey: "1024", + cfnstack.WorkloadTaskCountParamKey: "2", + cfnstack.WorkloadTaskCPUParamKey: "512", + cfnstack.WorkloadTaskMemoryParamKey: "1024", }, nil), m.ecsDescriber.EXPECT().Platform().Return(&ecs.ContainerPlatform{ OperatingSystem: "LINUX", diff --git a/internal/pkg/ecs/ecs.go b/internal/pkg/ecs/ecs.go index d97a5a9cac9..02871137717 100644 --- a/internal/pkg/ecs/ecs.go +++ b/internal/pkg/ecs/ecs.go @@ -112,17 +112,26 @@ func (c Client) DescribeService(app, env, svc string) (*ServiceDesc, error) { }, nil } -// LastUpdatedAt returns the last updated time of the ECS service. -func (c Client) LastUpdatedAt(app, env, svc string) (time.Time, error) { +// Service returns an ECS service given Copilot service info. +func (c Client) Service(app, env, svc string) (*ecs.Service, error) { clusterName, serviceName, err := c.fetchAndParseServiceARN(app, env, svc) if err != nil { - return time.Time{}, err + return nil, err } - detail, err := c.ecsClient.Service(clusterName, serviceName) + service, err := c.ecsClient.Service(clusterName, serviceName) if err != nil { - return time.Time{}, fmt.Errorf("get ECS service %s: %w", serviceName, err) + return nil, fmt.Errorf("get ECS service %s: %w", serviceName, err) + } + return service, nil +} + +// LastUpdatedAt returns the last updated time of the ECS service. +func (c Client) LastUpdatedAt(app, env, svc string) (time.Time, error) { + detail, err := c.Service(app, env, svc) + if err != nil { + return time.Time{}, err } - return aws.TimeValue(detail.Deployments[0].UpdatedAt), nil + return detail.LastUpdatedAt(), nil } // ListActiveAppEnvTasksOpts contains the parameters for ListActiveAppEnvTasks. @@ -277,6 +286,7 @@ type NetworkConfiguration ecs.NetworkConfiguration // UnmarshalJSON implements custom logic to unmarshal only the network configuration from a state machine definition. // Example state machine definition: +// // "Version": "1.0", // "Comment": "Run AWS Fargate task", // "StartAt": "Run Fargate Task", diff --git a/internal/pkg/ecs/ecs_test.go b/internal/pkg/ecs/ecs_test.go index cfacfd3b43a..73b80c3df94 100644 --- a/internal/pkg/ecs/ecs_test.go +++ b/internal/pkg/ecs/ecs_test.go @@ -329,7 +329,7 @@ func TestClient_DescribeService(t *testing.T) { } } -func TestClient_LastUpdatedAt(t *testing.T) { +func TestClient_Service(t *testing.T) { const ( mockApp = "mockApp" mockEnv = "mockEnv" @@ -339,8 +339,6 @@ func TestClient_LastUpdatedAt(t *testing.T) { mockService = "mockService" ) mockError := errors.New("some error") - mockTime := time.Unix(1494505756, 0) - mockBeforeTime := time.Unix(1494505750, 0) getRgInput := map[string]string{ deploy.AppTagKey: mockApp, deploy.EnvTagKey: mockEnv, @@ -351,7 +349,7 @@ func TestClient_LastUpdatedAt(t *testing.T) { setupMocks func(mocks clientMocks) wantedError error - wanted time.Time + wanted *ecs.Service }{ "error if fail to describe ECS service": { setupMocks: func(m clientMocks) { @@ -365,6 +363,89 @@ func TestClient_LastUpdatedAt(t *testing.T) { }, wantedError: fmt.Errorf("get ECS service mockService: some error"), }, + "err if failed to get the ECS service": { + setupMocks: func(m clientMocks) { + gomock.InOrder( + m.resourceGetter.EXPECT().GetResourcesByTags(serviceResourceType, getRgInput). + Return([]*resourcegroups.Resource{ + {ARN: mockSvcARN}, + }, nil), + m.ecsClient.EXPECT().Service(mockCluster, mockService).Return(nil, mockError), + ) + }, + wantedError: fmt.Errorf("get ECS service mockService: some error"), + }, + "success": { + setupMocks: func(m clientMocks) { + gomock.InOrder( + m.resourceGetter.EXPECT().GetResourcesByTags(serviceResourceType, getRgInput). + Return([]*resourcegroups.Resource{ + {ARN: mockSvcARN}, + }, nil), + m.ecsClient.EXPECT().Service(mockCluster, mockService).Return(&ecs.Service{}, nil), + ) + }, + wanted: &ecs.Service{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // GIVEN + mockRgGetter := mocks.NewMockresourceGetter(ctrl) + mockECSClient := mocks.NewMockecsClient(ctrl) + mocks := clientMocks{ + resourceGetter: mockRgGetter, + ecsClient: mockECSClient, + } + + test.setupMocks(mocks) + + client := Client{ + rgGetter: mockRgGetter, + ecsClient: mockECSClient, + } + + // WHEN + get, err := client.Service(mockApp, mockEnv, mockSvc) + + // THEN + if test.wantedError != nil { + require.EqualError(t, err, test.wantedError.Error()) + } else { + require.NoError(t, err) + require.Equal(t, get, test.wanted) + } + }) + } +} + +func TestClient_LastUpdatedAt(t *testing.T) { + const ( + mockApp = "mockApp" + mockEnv = "mockEnv" + mockSvc = "mockSvc" + mockSvcARN = "arn:aws:ecs:us-west-2:1234567890:service/mockCluster/mockService" + mockCluster = "mockCluster" + mockService = "mockService" + ) + mockTime := time.Unix(1494505756, 0) + mockBeforeTime := time.Unix(1494505750, 0) + getRgInput := map[string]string{ + deploy.AppTagKey: mockApp, + deploy.EnvTagKey: mockEnv, + deploy.ServiceTagKey: mockSvc, + } + + tests := map[string]struct { + setupMocks func(mocks clientMocks) + + wantedError error + wanted time.Time + }{ "succeed": { setupMocks: func(m clientMocks) { gomock.InOrder( diff --git a/internal/pkg/manifest/backend_svc.go b/internal/pkg/manifest/backend_svc.go index e064eabcfc8..f79f9243db3 100644 --- a/internal/pkg/manifest/backend_svc.go +++ b/internal/pkg/manifest/backend_svc.go @@ -98,23 +98,6 @@ 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 84ac90473cd..aaa883bd831 100644 --- a/internal/pkg/manifest/backend_svc_test.go +++ b/internal/pkg/manifest/backend_svc_test.go @@ -297,83 +297,6 @@ 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 3809d5173cd..a6686cd2fbe 100644 --- a/internal/pkg/manifest/lb_web_svc.go +++ b/internal/pkg/manifest/lb_web_svc.go @@ -164,11 +164,6 @@ 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 0227218b60e..80dcbc85901 100644 --- a/internal/pkg/manifest/lb_web_svc_test.go +++ b/internal/pkg/manifest/lb_web_svc_test.go @@ -1326,41 +1326,6 @@ 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/testdata/backend-svc-customhealthcheck.yml b/internal/pkg/manifest/testdata/backend-svc-customhealthcheck.yml index dc2d74d2e65..559ebcd5080 100644 --- a/internal/pkg/manifest/testdata/backend-svc-customhealthcheck.yml +++ b/internal/pkg/manifest/testdata/backend-svc-customhealthcheck.yml @@ -25,6 +25,8 @@ cpu: 256 # Number of CPU units for the task. memory: 512 # Amount of memory in MiB used by the task. count: 1 # Number of tasks that should be running in your service. exec: true # Enable running commands in your container. +network: + connect: true # Enable Service Connect for intra-environment traffic between services. # storage: # readonly_fs: true # Limit to read-only access to mounted root filesystems. diff --git a/internal/pkg/manifest/testdata/lb-svc.yml b/internal/pkg/manifest/testdata/lb-svc.yml index 641ca5da58a..a0b1533a7ba 100644 --- a/internal/pkg/manifest/testdata/lb-svc.yml +++ b/internal/pkg/manifest/testdata/lb-svc.yml @@ -25,6 +25,8 @@ cpu: 256 # Number of CPU units for the task. memory: 512 # Amount of memory in MiB used by the task. count: 1 # Number of tasks that should be running in your service. exec: true # Enable running commands in your container. +network: + connect: true # Enable Service Connect for intra-environment traffic between services. # storage: # readonly_fs: true # Limit to read-only access to mounted root filesystems. diff --git a/internal/pkg/manifest/validate.go b/internal/pkg/manifest/validate.go index 428a336766d..d7f7bdc1f66 100644 --- a/internal/pkg/manifest/validate.go +++ b/internal/pkg/manifest/validate.go @@ -204,11 +204,6 @@ func (b BackendService) validate() error { }); 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, @@ -252,6 +247,11 @@ func (b BackendServiceConfig) validate() error { if err = b.Network.validate(); err != nil { return fmt.Errorf(`validate "network": %w`, err) } + if b.Network.Connect.Alias != nil { + if b.RoutingRule.GetTargetContainer() == nil && b.ImageConfig.Port == nil { + return fmt.Errorf(`cannot set "network.connect.alias" when no ports are exposed`) + } + } if err = b.PublishConfig.validate(); err != nil { return fmt.Errorf(`validate "publish": %w`, err) } @@ -362,6 +362,9 @@ func (w WorkerServiceConfig) validate() error { if err = w.Network.validate(); err != nil { return fmt.Errorf(`validate "network": %w`, err) } + if w.Network.Connect.Alias != nil { + return fmt.Errorf(`cannot set "network.connect.alias" when no ports are exposed`) + } if err = w.Subscribe.validate(); err != nil { return fmt.Errorf(`validate "subscribe": %w`, err) } @@ -1083,8 +1086,8 @@ func (r RangeConfig) validate() error { min, max, spotFrom := aws.IntValue(r.Min), aws.IntValue(r.Max), aws.IntValue(r.SpotFrom) if min < 0 || max < 0 || spotFrom < 0 { return &errRangeValueLessThanZero{ - min: min, - max: max, + min: min, + max: max, spotFrom: spotFrom, } } diff --git a/internal/pkg/manifest/validate_test.go b/internal/pkg/manifest/validate_test.go index 0627613e1ef..60ffa16303e 100644 --- a/internal/pkg/manifest/validate_test.go +++ b/internal/pkg/manifest/validate_test.go @@ -601,7 +601,9 @@ func TestBackendService_validate(t *testing.T) { ImageConfig: testImageConfig, Network: NetworkConfig{ Connect: ServiceConnectBoolOrArgs{ - EnableServiceConnect: aws.Bool(true), + ServiceConnectArgs: ServiceConnectArgs{ + Alias: aws.String("some alias"), + }, }, }, }, @@ -609,7 +611,7 @@ func TestBackendService_validate(t *testing.T) { Name: aws.String("api"), }, }, - wantedError: fmt.Errorf(`cannot enable "network.connect" when no port exposed`), + wantedError: fmt.Errorf(`cannot set "network.connect.alias" when no ports are exposed`), }, } for name, tc := range testCases { @@ -959,6 +961,24 @@ func TestWorkerService_validate(t *testing.T) { }, wantedErrorMsgPrefix: `validate "deployment":`, }, + "error if service connect is enabled without any port exposed": { + config: WorkerService{ + WorkerServiceConfig: WorkerServiceConfig{ + ImageConfig: testImageConfig, + Network: NetworkConfig{ + Connect: ServiceConnectBoolOrArgs{ + ServiceConnectArgs: ServiceConnectArgs{ + Alias: aws.String("some alias"), + }, + }, + }, + }, + Workload: Workload{ + Name: aws.String("api"), + }, + }, + wantedError: fmt.Errorf(`cannot set "network.connect.alias" when no ports are exposed`), + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { @@ -2107,8 +2127,8 @@ func TestRangeConfig_validate(t *testing.T) { }, "error if spot_from value is negative": { RangeConfig: RangeConfig{ - Min: aws.Int(2), - Max: aws.Int(10), + Min: aws.Int(2), + Max: aws.Int(10), SpotFrom: aws.Int(-3), }, wantedError: fmt.Errorf("min value 2, max value 10, and spot_from value -3 must all be positive"), diff --git a/internal/pkg/manifest/workload.go b/internal/pkg/manifest/workload.go index 8a02e0184b8..6fd28c81196 100644 --- a/internal/pkg/manifest/workload.go +++ b/internal/pkg/manifest/workload.go @@ -481,6 +481,11 @@ type ServiceConnectBoolOrArgs struct { ServiceConnectArgs } +// Enabled returns if ServiceConnect is enabled or not. +func (s *ServiceConnectBoolOrArgs) Enabled() bool { + return aws.BoolValue(s.EnableServiceConnect) || !s.ServiceConnectArgs.isEmpty() +} + // 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. diff --git a/internal/pkg/manifest/workload_test.go b/internal/pkg/manifest/workload_test.go index 9fa794ee71b..77faf185752 100644 --- a/internal/pkg/manifest/workload_test.go +++ b/internal/pkg/manifest/workload_test.go @@ -354,6 +354,43 @@ func TestPlatformArgsOrString_UnmarshalYAML(t *testing.T) { } } +func TestServiceConnectBoolOrArgs_ServiceConnectEnabled(t *testing.T) { + testCases := map[string]struct { + mft *ServiceConnectBoolOrArgs + + wanted bool + }{ + "disabled by default": { + mft: &ServiceConnectBoolOrArgs{}, + wanted: false, + }, + "set by bool": { + mft: &ServiceConnectBoolOrArgs{ + EnableServiceConnect: aws.Bool(true), + }, + wanted: true, + }, + "set by args": { + mft: &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.Enabled() + + // THEN + require.Equal(t, tc.wanted, enabled) + }) + } +} + func TestServiceConnect_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 e066eaf68f6..7666eb94bd5 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,8 +33,8 @@ CapacityProviderStrategy: {{- end}} {{- end}} {{- end }} -{{- if and .ServiceConnect .SCFeatureFlag }} ServiceConnectConfiguration: + {{- if .ServiceConnect }} Enabled: True Namespace: {{.ServiceDiscoveryEndpoint}} LogConfiguration: @@ -43,6 +43,7 @@ ServiceConnectConfiguration: awslogs-region: !Ref AWS::Region awslogs-group: !Ref LogGroup awslogs-stream-prefix: copilot + {{- if .HTTPTargetContainer.Exposed}} Services: - PortName: target # Avoid using the same service with Service Discovery in a namespace. @@ -54,7 +55,10 @@ ServiceConnectConfiguration: {{- else}} DnsName: !Ref WorkloadName {{- end}} -{{- end}} + {{- end}} + {{- else}} + Enabled: False + {{- end}} NetworkConfiguration: AwsvpcConfiguration: AssignPublicIp: {{.Network.AssignPublicIP}} diff --git a/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml b/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml index 64691acb590..77e377ffaf5 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/sidecars.yml @@ -48,12 +48,10 @@ {{- if $sidecar.Port}} PortMappings: - ContainerPort: {{$sidecar.Port}} - {{- 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}} Protocol: {{$sidecar.Protocol}} {{- end}} 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 595c2ef2c91..4068fe4ef0f 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/workload-container.yml @@ -29,12 +29,10 @@ {{- if eq .WorkloadType "Load Balanced Web Service"}} PortMappings: - ContainerPort: !Ref ContainerPort - {{- 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 }} {{- if and (eq $nlbListener.TargetContainer .WorkloadName) (ne $nlbListener.TargetPort .NLB.MainContainerPort)}} {{/*No need to add additional port if the target port is the same as image port*/}} @@ -45,11 +43,7 @@ {{- end}} {{/* end if eq .WorkloadType "Load Balanced Web Service"*/}} {{- if eq .WorkloadType "Backend Service"}} {{- 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}} diff --git a/internal/pkg/template/templates/workloads/services/backend/manifest.yml b/internal/pkg/template/templates/workloads/services/backend/manifest.yml index f6e95c749d1..5b2001c17dd 100644 --- a/internal/pkg/template/templates/workloads/services/backend/manifest.yml +++ b/internal/pkg/template/templates/workloads/services/backend/manifest.yml @@ -43,7 +43,10 @@ count: {{.Count.Value}} # Number of tasks that should be running in your s {{- if not .TaskConfig.IsWindows }} exec: true # Enable running commands in your container. {{- end}} - +{{- if .ImageConfig.Port}} +network: + connect: true # Enable Service Connect for intra-environment traffic between services. +{{- end}} {{- if not .TaskConfig.IsWindows}} # storage: diff --git a/internal/pkg/template/templates/workloads/services/lb-web/manifest.yml b/internal/pkg/template/templates/workloads/services/lb-web/manifest.yml index 8cef9c72bad..4ddf13ed1f3 100644 --- a/internal/pkg/template/templates/workloads/services/lb-web/manifest.yml +++ b/internal/pkg/template/templates/workloads/services/lb-web/manifest.yml @@ -38,7 +38,8 @@ count: {{.Count.Value}} # Number of tasks that should be running in your s {{- if not .TaskConfig.IsWindows}} exec: true # Enable running commands in your container. {{- end}} - +network: + connect: true # Enable Service Connect for intra-environment traffic between services. {{- if not .TaskConfig.IsWindows}} # storage: diff --git a/internal/pkg/template/workload.go b/internal/pkg/template/workload.go index 47cb6c64530..5fe6d508107 100644 --- a/internal/pkg/template/workload.go +++ b/internal/pkg/template/workload.go @@ -67,6 +67,11 @@ const ( LogicalIDHTTPListenerRuleWithDomain = "HTTPListenerRuleWithDomain" ) +const ( + // NoExposedContainerPort indicates no port should be exposed for the service container. + NoExposedContainerPort = "-1" +) + var ( // Template names under "workloads/partials/cf/". partialsWorkloadCFTemplateNames = []string{ @@ -215,6 +220,11 @@ type HTTPTargetContainer struct { Port string } +// Exposed returns true if the target container has an accessible port to receive traffic. +func (tg HTTPTargetContainer) Exposed() bool { + return tg.Port != "" && tg.Port != NoExposedContainerPort +} + // IsHTTPS returns true if the target container's port is 443. func (tg HTTPTargetContainer) IsHTTPS() bool { return tg.Port == "443" @@ -600,8 +610,6 @@ type WorkloadOpts struct { // Additional options for worker service templates. Subscribe *SubscribeOpts - - SCFeatureFlag bool } // HealthCheckProtocol returns the protocol for the Load Balancer health check, diff --git a/mkdocs.yml b/mkdocs.yml index 7840b1ea723..2882cfeb2b9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,7 +59,7 @@ nav: - Observability: docs/developing/observability.en.md - Publish/Subscribe: docs/developing/publish-subscribe.en.md - Secrets: docs/developing/secrets.en.md - - Service Discovery: docs/developing/service-discovery.en.md + - Service-to-Service Communication: docs/developing/svc-to-svc-communication.en.md - Sidecars: docs/developing/sidecars.en.md - Storage: docs/developing/storage.en.md - Task Definition Overrides: docs/developing/taskdef-overrides.en.md @@ -210,6 +210,7 @@ plugins: 'docs/commands/pipeline-update.ja.md': 'docs/commands/pipeline-deploy.ja.md' 'docs/developing/additional-aws-resources.en.md': 'docs/developing/addons/modeling.en.md' 'docs/developing/additional-aws-resources.ja.md': 'docs/developing/addons/modeling.ja.md' + 'docs/developing/service-discovery.en.md': 'docs/developing/svc-to-svc-communication.en.md' - i18n: default_language: en languages: diff --git a/regression/multi-svc-app/back-end/main.go b/regression/multi-svc-app/back-end/main.go index 95ca94caa81..ebd467bc4e6 100644 --- a/regression/multi-svc-app/back-end/main.go +++ b/regression/multi-svc-app/back-end/main.go @@ -16,14 +16,14 @@ func HealthCheck(w http.ResponseWriter, req *http.Request, ps httprouter.Params) w.WriteHeader(http.StatusOK) } -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) w.Write([]byte("back-end")) } -// ServiceDiscoveryGet just returns true no matter what +// ServiceDiscoveryGet just returns true no matter what. func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get on ServiceDiscovery endpoint Succeeded") w.WriteHeader(http.StatusOK) diff --git a/regression/multi-svc-app/back-end/swap/main.go b/regression/multi-svc-app/back-end/swap/main.go index 3630891c110..ca7dd847993 100644 --- a/regression/multi-svc-app/back-end/swap/main.go +++ b/regression/multi-svc-app/back-end/swap/main.go @@ -16,14 +16,14 @@ func HealthCheck(w http.ResponseWriter, req *http.Request, ps httprouter.Params) w.WriteHeader(http.StatusOK) } -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) w.Write([]byte("back-end oraoraora")) // NOTE: response body appended with "oraoraora" } -// ServiceDiscoveryGet just returns true no matter what +// ServiceDiscoveryGet just returns true no matter what. func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get on ServiceDiscovery endpoint Succeeded") w.WriteHeader(http.StatusOK) diff --git a/regression/multi-svc-app/front-end/main.go b/regression/multi-svc-app/front-end/main.go index ef83f9f3f53..6b28fc7708d 100644 --- a/regression/multi-svc-app/front-end/main.go +++ b/regression/multi-svc-app/front-end/main.go @@ -13,7 +13,7 @@ import ( "github.com/julienschmidt/httprouter" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/regression/multi-svc-app/front-end/swap/main.go b/regression/multi-svc-app/front-end/swap/main.go index 1c3a3daa542..e9209405b48 100644 --- a/regression/multi-svc-app/front-end/swap/main.go +++ b/regression/multi-svc-app/front-end/swap/main.go @@ -13,7 +13,7 @@ import ( "github.com/julienschmidt/httprouter" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/regression/multi-svc-app/www/main.go b/regression/multi-svc-app/www/main.go index cf90a599c90..63572211d7d 100644 --- a/regression/multi-svc-app/www/main.go +++ b/regression/multi-svc-app/www/main.go @@ -10,7 +10,7 @@ import ( "github.com/julienschmidt/httprouter" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/regression/multi-svc-app/www/swap/main.go b/regression/multi-svc-app/www/swap/main.go index dd440fe85ae..dbd4fe3bc94 100644 --- a/regression/multi-svc-app/www/swap/main.go +++ b/regression/multi-svc-app/www/swap/main.go @@ -10,7 +10,7 @@ import ( "github.com/julienschmidt/httprouter" ) -// SimpleGet just returns true no matter what +// SimpleGet just returns true no matter what. func SimpleGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { log.Println("Get Succeeded") w.WriteHeader(http.StatusOK) diff --git a/site/content/blogs/release-v124.en.md b/site/content/blogs/release-v124.en.md new file mode 100644 index 00000000000..5ecb40e4b71 --- /dev/null +++ b/site/content/blogs/release-v124.en.md @@ -0,0 +1,110 @@ +--- +title: 'AWS Copilot v1.24: ECS Service Connect!' +twitter_title: 'AWS Copilot v1.24' +image: '' +image_alt: '' +image_width: '1051' +image_height: '747' +--- + +# AWS Copilot v1.24: ECS Service Connect! + +Posted On: Nov 28, 2022 + +The AWS Copilot core team is announcing the Copilot v1.24 release. +Our public [сommunity сhat](https://gitter.im/aws/copilot-cli) is growing and has over 350 people online and over 2.5k stars on [GitHub](http://github.com/aws/copilot-cli/). +Thanks to every one of you who shows love and support for AWS Copilot. + +Copilot v1.24 brings several new features and improvements: + +- **ECS Service Connect support**: [See detailed section](#ecs-service-connect-support). +- **Add `--no-rollback` flag to `env deploy`**: Copilot `env deploy` now has a new flag `--no-rollback`; you can specify the flag to disable automatic env deployment rollback to help with debugging. +- **Config autoscaling for Request-Driven Web Service**: It is now possible to specify autoscaling configuration for your RDWS. For example, this can be configured in your service manifest: +```yaml +count: high-availability/3 +``` +- **Add log retention to VPC flow logs**: There is now a default value of 14 days. + ```yaml +network: + vpc: + flow_logs: on + ``` + Alternatively, you can customize the number of days for retention: + ```yaml +network: + vpc: + flow_logs: + retention: 30 + ``` + + +???+ note "What’s AWS Copilot?" + + The AWS Copilot CLI is a tool for developers to build, release, and operate production ready containerized applications on AWS. + From getting started, pushing to staging, and releasing to production, Copilot can help manage the entire lifecycle of your application development. + At the foundation of Copilot is AWS CloudFormation, which enables you to provision infrastructure as code. + Copilot provides pre-defined CloudFormation templates and user-friendly workflows for different types of micro service architectures, + enabling you to focus on developing your application, instead of writing deployment scripts. + + See the section [Overview](../docs/concepts/overview.en.md) for a more detailed introduction to AWS Copilot. + +## ECS Service Connect Support +[Copilot supports](../docs/developing/svc-to-svc-communication.en.md#service-connect) the newly launched [ECS Service Connect](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect.html)! Your private service-to-service communication will be more resilient and load-balanced with Service Connect than with Service Discovery. Let's walk through how Copilot supports ECS Service Connect. + +### (Optional) Deploy an example service +If you don't have any existing services deployed, please follow [our tutorial](../docs/getting-started/first-app-tutorial.en.md) to deploy a simple front-end service that is accessible in your browser. + +### Set up Service Connect +In addition to Service Discovery, you can set up Service Connect with the following configuration in your service manifest. + +```yaml +network: + connect: true +``` + +!!! attention + In order to use Service Connect, both server and client services need to have Service Connect enabled. + +### Check out the generated endpoint +After successfully deploying with the updated manifest, Service Connect should be enabled for your service. You can run `copilot svc show` to get the endpoint URL for your service. + +``` +$ copilot svc show --name front-end + +... +Internal Service Endpoint + + Endpoint Environment Type + -------- ----------- ---- + front-end:80 test Service Connect + front-end.test.demo.local:80 test Service Discovery +... +``` +As shown above, `front-end:80` is your Service Connect endpoint that your other client services can call. (They must have Service Connect enabled as well.) + +### (Optional) Verify that it works +To verify the IP address for your Service Connect endpoint URL has indeed been added to your service network, you can simply use `copilot svc exec` to execute into your container and check out the hosts file. + +``` +$ copilot svc exec --name front-end +Execute `/bin/sh` in container frontend in task a2d57c4b40014a159d3b2e3ec7b73004. + +Starting session with SessionId: ecs-execute-command-088d464a5721fuej3f +# cat /etc/hosts +127.0.0.1 localhost +10.0.1.253 ip-10-0-1-253.us-west-2.compute.internal +127.255.0.1 front-end +2600:f0f0:0:0:0:0:0:1 front-end +# exit + + +Exiting session with sessionId: ecs-execute-command-088d464a5721fuej3f. +``` + +## What’s next? + +Download the new Copilot CLI version by following the link below and leave your feedback on [GitHub](https://github.com/aws/copilot-cli/) or our [Community Chat](https://gitter.im/aws/copilot-cli): + +- Download [the latest CLI version](../docs/getting-started/install.en.md) +- Try our [Getting Started Guide](../docs/getting-started/first-app-tutorial.en.md) +- Read full release notes on [GitHub](https://github.com/aws/copilot-cli/releases/tag/v1.24.0) diff --git a/site/content/docs/developing/environment-variables.en.md b/site/content/docs/developing/environment-variables.en.md index 81f43cca14a..8a495c34746 100644 --- a/site/content/docs/developing/environment-variables.en.md +++ b/site/content/docs/developing/environment-variables.en.md @@ -27,7 +27,7 @@ By default, the AWS Copilot CLI passes in some default environment variables for * `COPILOT_ENVIRONMENT_NAME` - this is the name of the environment the service is running in (test vs prod, for example) * `COPILOT_SERVICE_NAME` - this is the name of the current service. * `COPILOT_LB_DNS` - this is the DNS name of the Load Balancer (if it exists) such as _kudos-Publi-MC2WNHAIOAVS-588300247.us-west-2.elb.amazonaws.com_. Note: if you're using a custom domain name, this value will still be the Load Balancer's DNS name. -* `COPILOT_SERVICE_DISCOVERY_ENDPOINT` - this is the endpoint to add after a service name to talk to another service in your environment via service discovery. The value is `{env name}.{app name}.local`. For more information about service discovery, check out our [Service Discovery guide](../developing/service-discovery.en.md). +* `COPILOT_SERVICE_DISCOVERY_ENDPOINT` - this is the endpoint to add after a service name to talk to another service in your environment via service discovery. The value is `{env name}.{app name}.local`. For more information about service discovery, check out our [Service Discovery guide](../developing/svc-to-svc-communication.en.md#service-discovery). ## How do I add my own Environment Variables? diff --git a/site/content/docs/developing/service-discovery.en.md b/site/content/docs/developing/service-discovery.en.md deleted file mode 100644 index 42a54e679f2..00000000000 --- a/site/content/docs/developing/service-discovery.en.md +++ /dev/null @@ -1,41 +0,0 @@ -# Service Discovery - -Service Discovery is a way of letting services discover and connect with each other. Typically, services can only talk to each other if they expose a public endpoint - and even then, requests will have to go over the internet. With [ECS Service Discovery](https://docs.aws.amazon.com/whitepapers/latest/microservices-on-aws/service-discovery.html), each service you create is given a private address and DNS name - meaning each service can talk to another without ever leaving the local network (VPC) and without exposing a public endpoint. - -## How do I use Service Discovery? - -Service Discovery is enabled for all services set up using the Copilot CLI. We'll show you how to use it by using an example. Imagine we have an app called `kudos` and two services: `api` and `front-end`. - -In this example we'll imagine our `front-end` service is deployed in the `test` environment, has a public endpoint and wants to call our `api` service using its service discovery endpoint. - -```go -// Calling our api service from the front-end service using Service Discovery -func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { - endpoint := fmt.Sprintf("http://api.%s/some-request", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) - resp, err := http.Get(endpoint /* http://api.test.kudos.local/some-request */) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) - w.WriteHeader(http.StatusOK) - w.Write(body) -} -``` - -The important part is that our `front-end` service is making a request to our `api` service through a special endpoint: - -```go -endpoint := fmt.Sprintf("http://api.%s/some-request", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) -``` - -`COPILOT_SERVICE_DISCOVERY_ENDPOINT` is a special environment variable that the Copilot CLI sets for you when it creates your service. It's of the format _{env name}.{app name}.local_ - so in this case in our _kudos_ app, when deployed in the _test_ environment, the request would be to `http://api.test.kudos.local/some-request`. Since our _api_ service is running on port 80, we're not specifying the port in the URL. However, if it was running on another port, say 8080, we'd need to include the port in the request, as well `http://api.test.kudos.local:8080/some-request`. - -When our front-end makes this request, the endpoint `api.test.kudos.local` resolves to a private IP address and is routed privately within your VPC. - -## Legacy Environments and Service Discovery - -Prior to Copilot v1.9.0, the service discovery namespace used the format _{app name}.local_, without including the environment. This limitation made it impossible to deploy multiple environments in the same VPC. Any environments created with Copilot v1.9.0 and newer can share a VPC with any other environment. - -When your environments are upgraded, Copilot will honor the service discovery namespace that the environment was created with. That means that the endpoint your services are reachable at will not change. Any new environments created with Copilot v1.9.0 and above will use the _{env name}.{app name}.local_ format for service discovery, and can share VPCs with older environments. diff --git a/site/content/docs/developing/svc-to-svc-communication.en.md b/site/content/docs/developing/svc-to-svc-communication.en.md new file mode 100644 index 00000000000..437ad9a8cea --- /dev/null +++ b/site/content/docs/developing/svc-to-svc-communication.en.md @@ -0,0 +1,87 @@ +# Service-to-Service Communication + +## Service Connect added in v1.24.0 + +[ECS Service Connect](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect.html) enables a client service to connect to its downstream services in a load-balanced and resilient fashion. Furthermore, it simplifies the way of exposing a service to its clients by specifying friendly aliases. With Service Connect in Copilot, each service you create is given the following private alias by default: `http://`. + +!!! attention + Service Connect is not yet supported for [Request-Driven Web Services](../docs/concepts/services.en.md#request-driven-web-service). + +### How do I use Service Connect? +Imagine we have an app called `kudos` and two services: `api` and `front-end`, deployed in the same environment. In order to use Service Connect, both services' manifests need to have: + +???+ note "Sample Service Connect manifest setups" + + === "Basic" + ```yaml + network: + connect: true # Defaults to "false" + ``` + + === "Custom Alias" + ```yaml + network: + connect: + alias: frontend.local + ``` + +After deploying both services, they should be able to talk to each other using the default Service Connect endpoint, which is the same as its service name. For example, `front-end` service can simply call `http://api`. + +```go +// Calling the "api" service from the "front-end" service. +resp, err := http.Get("http://api/") +``` + +### Upgrading from Service Discovery + +Prior to v1.24, Copilot enabled private service-to-service communication with [Service Discovery](#service-discovery). If you are already using Service Discovery and want to avoid any code changes, you can configure [`network.connect.alias`](../manifest/lb-web-service.en.md#network-connect-alias) field so that the Service Connect uses the same alias as Service Discovery. And if **both** the service and its client have Service Connect enabled, they'll connect via Service Connect instead of Service Discovery. For example, in the manifest of the `api` service we have + +```yaml +network: + connect: + alias: ${COPILOT_SERVICE_NAME}.${COPILOT_ENVIRONMENT_NAME}.${COPILOT_APPLICATION_NAME}.local +``` + +and `front-end` also has the same setting. Then, they can keep using the same endpoint to make API calls via Service Connect instead of Service Discovery to leverage the benefits of load balancing and additional resiliency. + +## Service Discovery + +Service Discovery is a way of letting services discover and connect with each other. Typically, services can only talk to each other if they expose a public endpoint - and even then, requests will have to go over the internet. With [ECS Service Discovery](https://docs.aws.amazon.com/whitepapers/latest/microservices-on-aws/service-discovery.html), each service you create is given a private address and DNS name - meaning each service can talk to another without ever leaving the local network (VPC) and without exposing a public endpoint. + +### How do I use Service Discovery? + +Service Discovery is enabled for all services set up using the Copilot CLI. We'll show you how to use it by using an example. Imagine we have the same `kudos` app with two services: `api` and `front-end`. + +In this example we'll imagine our `front-end` service is deployed in the `test` environment, has a public endpoint and wants to call our `api` service using its service discovery endpoint. + +```go +// Calling our api service from the front-end service using Service Discovery +func ServiceDiscoveryGet(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { + endpoint := fmt.Sprintf("http://api.%s/some-request", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) + resp, err := http.Get(endpoint /* http://api.test.kudos.local/some-request */) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + w.WriteHeader(http.StatusOK) + w.Write(body) +} +``` + +The important part is that our `front-end` service is making a request to our `api` service through a special endpoint: + +```go +endpoint := fmt.Sprintf("http://api.%s/some-request", os.Getenv("COPILOT_SERVICE_DISCOVERY_ENDPOINT")) +``` + +`COPILOT_SERVICE_DISCOVERY_ENDPOINT` is a special environment variable that the Copilot CLI sets for you when it creates your service. It's of the format _{env name}.{app name}.local_ - so in this case in our _kudos_ app, when deployed in the _test_ environment, the request would be to `http://api.test.kudos.local/some-request`. Since our _api_ service is running on port 80, we're not specifying the port in the URL. However, if it was running on another port, say 8080, we'd need to include the port in the request, as well `http://api.test.kudos.local:8080/some-request`. + +When our front-end makes this request, the endpoint `api.test.kudos.local` resolves to a private IP address and is routed privately within your VPC. + +### Legacy Environments and Service Discovery + +Prior to Copilot v1.9.0, the service discovery namespace used the format _{app name}.local_, without including the environment. This limitation made it impossible to deploy multiple environments in the same VPC. Any environments created with Copilot v1.9.0 and newer can share a VPC with any other environment. + +When your environments are upgraded, Copilot will honor the service discovery namespace that the environment was created with. That means that endpoints for your services will not change. Any new environments created with Copilot v1.9.0 and above will use the _{env name}.{app name}.local_ format for service discovery, and can share VPCs with older environments. diff --git a/site/content/docs/include/network.en.md b/site/content/docs/include/network.en.md index f233a5bb0fa..e9a2257e08a 100644 --- a/site/content/docs/include/network.en.md +++ b/site/content/docs/include/network.en.md @@ -3,6 +3,14 @@ `network` Map The `network` section contains parameters for connecting to AWS resources in a VPC. +network.`connect` Bool or Map +Enable [Service Connect](../developing/svc-to-svc-communication.en.md#service-connect) for your service, which makes the traffic between services load balanced and more resilient. Defaults to `false`. + +When using it as a map, you can specify which alias to use for this service. Note that the alias must be unique within the environment. + +network.`alias` String +A custom DNS name for this service exposed to Service Connect. Defaults to the service name. + network.`vpc` Map Subnets and security groups attached to your tasks. diff --git a/site/content/docs/manifest/backend-service.en.md b/site/content/docs/manifest/backend-service.en.md index f1451234001..f0d151bbf8f 100644 --- a/site/content/docs/manifest/backend-service.en.md +++ b/site/content/docs/manifest/backend-service.en.md @@ -2,10 +2,9 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab ???+ note "Sample backend service manifests" - === "Service Discovery" + === "Serving Internal Traffic" ```yaml - # Your service is reachable at "http://api.${COPILOT_SERVICE_DISCOVERY_ENDPOINT}:8080" only within your VPC. name: api type: Backend Service @@ -18,12 +17,15 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab retries: 2 timeout: 5s start_period: 0s - + + network: + connect: true + cpu: 256 memory: 512 count: 2 exec: true - + env_file: ./api/.env environments: test: @@ -40,7 +42,7 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab # behind an internal load balancer only within your VPC. name: api type: Backend Service - + image: build: ./api/Dockerfile port: 8080 @@ -80,7 +82,7 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab # See https://aws.github.io/copilot-cli/docs/manifest/environment/#http-private-certificates name: api type: Backend Service - + image: build: ./api/Dockerfile port: 8080 @@ -99,7 +101,7 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab # See https://aws.github.io/copilot-cli/docs/developing/publish-subscribe/ name: warehouse type: Backend Service - + image: build: ./warehouse/Dockerfile port: 80 @@ -134,7 +136,7 @@ List of all available properties for a `'Backend Service'` manifest. To learn ab storage: volumes: - userdata: + userdata: path: /etc/mount1 efs: id: fs-1234567 @@ -146,7 +148,7 @@ The name of your service.
`type` String -The architecture type for your service. [Backend Services](../concepts/services.en.md#backend-service) are not reachable from the internet, but can be reached with [service discovery](../developing/service-discovery.en.md) from your other services. +The architecture type for your service. [Backend Services](../concepts/services.en.md#backend-service) are not reachable from the internet, but can be reached with [service discovery](../developing/svc-to-svc-communication.en.md#service-discovery) from your other services.
@@ -217,7 +219,7 @@ TLS connections with the Fargate tasks using certificates that you install on th
-`count` Integer or Map +`count` Integer or Map The number of tasks that your service should maintain. If you specify a number: diff --git a/site/content/docs/manifest/rd-web-service.en.md b/site/content/docs/manifest/rd-web-service.en.md index 79ecd15310f..c72ae92932a 100644 --- a/site/content/docs/manifest/rd-web-service.en.md +++ b/site/content/docs/manifest/rd-web-service.en.md @@ -8,11 +8,11 @@ List of all available properties for a `'Request-Driven Web Service'` manifest. # Deploys a web service accessible at https://web.example.com. name: frontend type: Request-Driven Web Service - + http: healthcheck: '/_healthcheck' alias: web.example.com - + image: build: ./frontend/Dockerfile port: 80 @@ -25,7 +25,7 @@ List of all available properties for a `'Request-Driven Web Service'` manifest. owner: frontend observability: tracing: awsxray - + environments: test: variables: @@ -187,7 +187,7 @@ Amount of memory in MiB reserved for each instance of your service. See the [AWS `network` Map The `network` section contains parameters for connecting the service to AWS resources in the environment's VPC. -By connecting the service to a VPC, you can use [service discovery](../developing/service-discovery.en.md) to communicate with other services +By connecting the service to a VPC, you can use [service discovery](../developing/svc-to-svc-communication.en.md#service-discovery) to communicate with other services in your environment, or connect to a database in your VPC such as Amazon Aurora with [`storage init`](../commands/storage-init.en.md). network.`vpc` Map @@ -198,7 +198,7 @@ The only valid option today is `'private'`. If you prefer the service not to be When the placement is `'private'`, the App Runner service routes egress traffic through the private subnets of the VPC. If you use a Copilot-generated VPC, Copilot will automatically add NAT Gateways to your environment for internet connectivity. (See [pricing](https://aws.amazon.com/vpc/pricing/).) -Alternatively, when running `copilot env init`, you can import an existing VPC with NAT Gateways, or one with VPC endpoints +Alternatively, when running `copilot env init`, you can import an existing VPC with NAT Gateways, or one with VPC endpoints for isolated workloads. See our [custom environment resources](../developing/custom-environment-resources.en.md) page for more. {% include 'observability.en.md' %} @@ -232,4 +232,3 @@ count: high-availability/3 `environments` Map The environment section lets you override any value in your manifest based on the environment you're in. In the example manifest above, we're overriding the `LOG_LEVEL` environment variable in our 'test' environment. -