internal/pkg/describe/service.go (474 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package describe
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"sort"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/arn"
"github.com/aws/copilot-cli/internal/pkg/aws/apprunner"
"github.com/aws/copilot-cli/internal/pkg/aws/cloudwatch"
awsecs "github.com/aws/copilot-cli/internal/pkg/aws/ecs"
"github.com/aws/copilot-cli/internal/pkg/config"
"github.com/aws/copilot-cli/internal/pkg/describe/stack"
"github.com/aws/copilot-cli/internal/pkg/ecs"
)
const (
// Ignored resources
rulePriorityFunction = "Custom::RulePriorityFunction"
waitCondition = "AWS::CloudFormation::WaitCondition"
waitConditionHandle = "AWS::CloudFormation::WaitConditionHandle"
)
const (
apprunnerServiceType = "AWS::AppRunner::Service"
apprunnerVPCIngressConnectionType = "AWS::AppRunner::VpcIngressConnection"
)
const maxAlarmShowColumnWidth = 40
// ConfigStoreSvc wraps methods of config store.
type ConfigStoreSvc interface {
GetEnvironment(appName string, environmentName string) (*config.Environment, error)
ListEnvironments(appName string) ([]*config.Environment, error)
ListServices(appName string) ([]*config.Workload, error)
GetWorkload(appName string, name string) (*config.Workload, error)
ListJobs(appName string) ([]*config.Workload, error)
}
// DeployedEnvServicesLister wraps methods of deploy store.
type DeployedEnvServicesLister interface {
ListEnvironmentsDeployedTo(appName string, svcName string) ([]string, error)
ListDeployedServices(appName string, envName string) ([]string, error)
ListDeployedJobs(appName string, envName string) ([]string, error)
}
type ecsClient interface {
TaskDefinition(app, env, svc string) (*awsecs.TaskDefinition, error)
Service(app, env, svc string) (*awsecs.Service, error)
}
type apprunnerClient interface {
DescribeService(svcARN string) (*apprunner.Service, error)
PrivateURL(vicARN string) (string, error)
}
type workloadDescriber interface {
Params() (map[string]string, error)
Outputs() (map[string]string, error)
StackResources() ([]*stack.Resource, error)
Manifest() ([]byte, error)
}
type ecsDescriber interface {
workloadDescriber
ServiceConnectDNSNames() ([]string, error)
Platform() (*awsecs.ContainerPlatform, error)
EnvVars() ([]*awsecs.ContainerEnvVar, error)
Secrets() ([]*awsecs.ContainerSecret, error)
RollbackAlarmNames() ([]string, error)
}
type apprunnerDescriber interface {
workloadDescriber
Service() (*apprunner.Service, error)
ServiceARN() (string, error)
ServiceURL() (string, error)
IsPrivate() (bool, error)
}
type cwAlarmDescriber interface {
AlarmDescriptions([]string) ([]*cloudwatch.AlarmDescription, error)
}
type bucketDescriber interface {
BucketTree(bucket string) (string, error)
}
type bucketDataGetter interface {
BucketSizeAndCount(bucket string) (string, int, error)
}
type bucketNameGetter interface {
BucketName(app, env, svc string) (string, error)
}
type ecsSvcDesc struct {
Service string `json:"service"`
Type string `json:"type"`
App string `json:"application"`
Configurations ecsConfigurations `json:"configurations"`
AlarmDescriptions []*cloudwatch.AlarmDescription `json:"rollbackAlarms,omitempty"`
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:"-"`
}
type ecsServiceDescriber struct {
*WorkloadStackDescriber
ecsClient ecsClient
}
type appRunnerServiceDescriber struct {
*WorkloadStackDescriber
apprunnerClient apprunnerClient
}
// NewServiceConfig contains fields that initiates service describer struct.
type NewServiceConfig struct {
App string
Env string
Svc string
ConfigStore ConfigStoreSvc
EnableResources bool
DeployStore DeployedEnvServicesLister
}
func newECSServiceDescriber(opt NewServiceConfig) (*ecsServiceDescriber, error) {
stackDescriber, err := NewWorkloadStackDescriber(NewWorkloadConfig{
App: opt.App,
Env: opt.Env,
Name: opt.Svc,
ConfigStore: opt.ConfigStore,
})
if err != nil {
return nil, err
}
return &ecsServiceDescriber{
WorkloadStackDescriber: stackDescriber,
ecsClient: ecs.New(stackDescriber.sess),
}, nil
}
func newAppRunnerServiceDescriber(opt NewServiceConfig) (*appRunnerServiceDescriber, error) {
stackDescriber, err := NewWorkloadStackDescriber(NewWorkloadConfig{
App: opt.App,
Env: opt.Env,
Name: opt.Svc,
ConfigStore: opt.ConfigStore,
})
if err != nil {
return nil, err
}
return &appRunnerServiceDescriber{
WorkloadStackDescriber: stackDescriber,
apprunnerClient: apprunner.New(stackDescriber.sess),
}, nil
}
// EnvVars returns the environment variables of the task definition.
func (d *ecsServiceDescriber) EnvVars() ([]*awsecs.ContainerEnvVar, error) {
taskDefinition, err := d.ecsClient.TaskDefinition(d.app, d.env, d.name)
if err != nil {
return nil, fmt.Errorf("describe task definition for service %s: %w", d.name, err)
}
return taskDefinition.EnvironmentVariables(), nil
}
// Secrets returns the secrets of the task definition.
func (d *ecsServiceDescriber) Secrets() ([]*awsecs.ContainerSecret, error) {
taskDefinition, err := d.ecsClient.TaskDefinition(d.app, d.env, d.name)
if err != nil {
return nil, fmt.Errorf("describe task definition for service %s: %w", d.name, err)
}
return taskDefinition.Secrets(), nil
}
// Platform returns the platform of the task definition.
func (d *ecsServiceDescriber) Platform() (*awsecs.ContainerPlatform, error) {
taskDefinition, err := d.ecsClient.TaskDefinition(d.app, d.env, d.name)
if err != nil {
return nil, fmt.Errorf("describe task definition for service %s: %w", d.name, err)
}
platform := taskDefinition.Platform()
if platform == nil {
return &awsecs.ContainerPlatform{
OperatingSystem: "LINUX",
Architecture: "X86_64",
}, nil
}
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.name)
if err != nil {
return nil, fmt.Errorf("get service %s: %w", d.name, err)
}
return service.ServiceConnectAliases(), nil
}
// RollbackAlarmNames returns the rollback alarm names of a service.
func (d *ecsServiceDescriber) RollbackAlarmNames() ([]string, error) {
service, err := d.ecsClient.Service(d.app, d.env, d.name)
if err != nil {
return nil, fmt.Errorf("get service %s: %w", d.name, err)
}
if service.DeploymentConfiguration.Alarms == nil {
return nil, nil
}
return aws.StringValueSlice(service.DeploymentConfiguration.Alarms.AlarmNames), nil
}
// ServiceARN retrieves the ARN of the app runner service.
func (d *appRunnerServiceDescriber) ServiceARN() (string, error) {
StackResources, err := d.StackResources()
if err != nil {
return "", err
}
for _, resource := range StackResources {
arn := resource.PhysicalID
if resource.Type == apprunnerServiceType && arn != "" {
return arn, nil
}
}
return "", fmt.Errorf("no App Runner Service in service stack")
}
// vpcIngressConnectionARN returns the ARN of the VPC Ingress Connection
// for this service. If one does not exist, it returns errVPCIngressConnectionNotFound.
func (d *appRunnerServiceDescriber) vpcIngressConnectionARN() (string, error) {
StackResources, err := d.StackResources()
if err != nil {
return "", err
}
for _, resource := range StackResources {
arn := resource.PhysicalID
if resource.Type == apprunnerVPCIngressConnectionType && arn != "" {
return arn, nil
}
}
return "", errVPCIngressConnectionNotFound
}
// Service retrieves an app runner service.
func (d *appRunnerServiceDescriber) Service() (*apprunner.Service, error) {
serviceARN, err := d.ServiceARN()
if err != nil {
return nil, err
}
service, err := d.apprunnerClient.DescribeService(serviceARN)
if err != nil {
return nil, fmt.Errorf("describe service: %w", err)
}
return service, nil
}
// IsPrivate returns true if the service is configured as non-public.
func (d *appRunnerServiceDescriber) IsPrivate() (bool, error) {
_, err := d.vpcIngressConnectionARN()
if err != nil {
if errors.Is(err, errVPCIngressConnectionNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
// ServiceURL retrieves the app runner service URL.
func (d *appRunnerServiceDescriber) ServiceURL() (string, error) {
vicARN, err := d.vpcIngressConnectionARN()
isVICNotFound := errors.Is(err, errVPCIngressConnectionNotFound)
if err != nil && !isVICNotFound {
return "", err
}
if !isVICNotFound {
url, err := d.apprunnerClient.PrivateURL(vicARN)
if err != nil {
return "", err
}
return formatAppRunnerURL(url), nil
}
service, err := d.Service()
if err != nil {
return "", err
}
return formatAppRunnerURL(service.ServiceURL), nil
}
func formatAppRunnerURL(serviceURL string) string {
svcUrl := &url.URL{
Host: serviceURL,
// App Runner defaults to https
Scheme: "https",
}
return svcUrl.String()
}
// ServiceConfig contains serialized configuration parameters for a service.
type ServiceConfig struct {
Environment string `json:"environment"`
Port string `json:"port"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
Platform string `json:"platform,omitempty"`
}
// ECSServiceConfig contains info about how an ECS-based service is configured.
type ECSServiceConfig struct {
*ServiceConfig
Tasks string `json:"tasks"`
}
type appRunnerConfigurations []*ServiceConfig
type ecsConfigurations []*ECSServiceConfig
func (c ecsConfigurations) humanString(w io.Writer) {
headers := []string{"Environment", "Tasks", "CPU (vCPU)", "Memory (MiB)", "Platform", "Port"}
var rows [][]string
for _, config := range c {
rows = append(rows, []string{config.Environment, config.Tasks, cpuToString(config.CPU), config.Memory, config.Platform, config.Port})
}
printTable(w, headers, rows)
}
func (c appRunnerConfigurations) humanString(w io.Writer) {
headers := []string{"Environment", "CPU (vCPU)", "Memory (MiB)", "Port"}
var rows [][]string
for _, config := range c {
rows = append(rows, []string{config.Environment, cpuToString(config.CPU), config.Memory, config.Port})
}
printTable(w, headers, rows)
}
type rollbackAlarms []*cloudwatch.AlarmDescription
func (abr rollbackAlarms) humanString(w io.Writer) {
headers := []string{"Name", "Environment", "Description"}
fmt.Fprintf(w, " %s\n", strings.Join(headers, "\t"))
fmt.Fprintf(w, " %s\n", strings.Join(underline(headers), "\t"))
for _, alarm := range abr {
printWithMaxWidth(w, " %s\t%s\t%s\n", maxAlarmShowColumnWidth, alarm.Name, alarm.Environment, alarm.Description)
}
}
// envVar contains serialized environment variables for a service.
type envVar struct {
Environment string `json:"environment"`
Name string `json:"name"`
Value string `json:"value"`
}
type envVars []*envVar
func (e envVars) humanString(w io.Writer) {
headers := []string{"Name", "Environment", "Value"}
var rows [][]string
sort.SliceStable(e, func(i, j int) bool { return e[i].Environment < e[j].Environment })
sort.SliceStable(e, func(i, j int) bool { return e[i].Name < e[j].Name })
for _, v := range e {
rows = append(rows, []string{v.Name, v.Environment, v.Value})
}
printTable(w, headers, rows)
}
type containerEnvVar struct {
*envVar
Container string `json:"container"`
}
type containerEnvVars []*containerEnvVar
func (e containerEnvVars) humanString(w io.Writer) {
headers := []string{"Name", "Container", "Environment", "Value"}
var rows [][]string
sort.SliceStable(e, func(i, j int) bool { return e[i].Environment < e[j].Environment })
sort.SliceStable(e, func(i, j int) bool { return e[i].Container < e[j].Container })
sort.SliceStable(e, func(i, j int) bool { return e[i].Name < e[j].Name })
for _, v := range e {
rows = append(rows, []string{v.Name, v.Container, v.Environment, v.Value})
}
printTable(w, headers, rows)
}
// rdwsSecret contains secrets for an rdws service.
type rdwsSecret struct {
Environment string `json:"environment"`
Name string `json:"name"`
ValueFrom string `json:"value"`
}
type rdwsSecrets []*rdwsSecret
func (e rdwsSecrets) humanString(w io.Writer) {
headers := []string{"Name", "Environment", "Value"}
var rows [][]string
sort.SliceStable(e, func(i, j int) bool {
if e[i].Name == e[j].Name {
return e[i].Environment < e[j].Environment
}
return e[i].Name < e[j].Name
})
for _, v := range e {
rows = append(rows, []string{v.Name, v.Environment, v.ValueFrom})
}
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")
}
}