// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package describe import ( "bytes" "encoding/json" "fmt" "sort" "strings" "text/tabwriter" "github.com/aws/copilot-cli/internal/pkg/template" "github.com/aws/copilot-cli/internal/pkg/version" "gopkg.in/yaml.v3" "github.com/aws/copilot-cli/internal/pkg/aws/ec2" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" "github.com/aws/copilot-cli/internal/pkg/config" cfnstack "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/describe/stack" "github.com/aws/copilot-cli/internal/pkg/manifest" "github.com/aws/copilot-cli/internal/pkg/term/color" ) var ( fmtLegacySvcDiscoveryEndpoint = "%s.local" ) type vpcSubnetLister interface { ListVPCSubnets(vpcID string) (*ec2.VPCSubnets, error) } // EnvDescription contains the information about an environment. type EnvDescription struct { Environment *config.Environment `json:"environment"` Services []*config.Workload `json:"services"` Jobs []*config.Workload `json:"jobs"` Tags map[string]string `json:"tags,omitempty"` Resources []*stack.Resource `json:"resources,omitempty"` EnvironmentVPC EnvironmentVPC `json:"environmentVPC"` } // EnvironmentVPC holds the ID of the environment's VPC configuration. type EnvironmentVPC struct { ID string `json:"id"` PublicSubnetIDs []string `json:"publicSubnetIDs"` PrivateSubnetIDs []string `json:"privateSubnetIDs"` } // EnvDescriber retrieves information about an environment. type EnvDescriber struct { app string env *config.Environment enableResources bool configStore ConfigStoreSvc deployStore DeployedEnvServicesLister cfn stackDescriber subnetLister vpcSubnetLister // Cached values for reuse. description *EnvDescription } // NewEnvDescriberConfig contains fields that initiates EnvDescriber struct. type NewEnvDescriberConfig struct { App string Env string EnableResources bool ConfigStore ConfigStoreSvc DeployStore DeployedEnvServicesLister } // NewEnvDescriber instantiates an environment describer. func NewEnvDescriber(opt NewEnvDescriberConfig) (*EnvDescriber, error) { env, err := opt.ConfigStore.GetEnvironment(opt.App, opt.Env) if err != nil { return nil, fmt.Errorf("get environment: %w", err) } sess, err := sessions.ImmutableProvider().FromRole(env.ManagerRoleARN, env.Region) if err != nil { return nil, fmt.Errorf("assume role for environment %s: %w", env.ManagerRoleARN, err) } return &EnvDescriber{ app: opt.App, env: env, enableResources: opt.EnableResources, configStore: opt.ConfigStore, deployStore: opt.DeployStore, cfn: stack.NewStackDescriber(cfnstack.NameForEnv(opt.App, opt.Env), sess), subnetLister: ec2.New(sess), }, nil } // Describe returns info about an application's environment. func (d *EnvDescriber) Describe() (*EnvDescription, error) { if d.description != nil { return d.description, nil } svcs, err := d.filterDeployedSvcs() if err != nil { return nil, err } jobs, err := d.filterDeployedJobs() if err != nil { return nil, err } tags, environmentVPC, err := d.loadStackInfo() if err != nil { return nil, err } var stackResources []*stack.Resource if d.enableResources { stackResources, err = d.cfn.Resources() if err != nil { return nil, fmt.Errorf("retrieve environment resources: %w", err) } } d.description = &EnvDescription{ Environment: d.env, Services: svcs, Jobs: jobs, Tags: tags, Resources: stackResources, EnvironmentVPC: environmentVPC, } return d.description, nil } // Manifest returns the contents of the manifest used to deploy an environment stack. func (d *EnvDescriber) Manifest() ([]byte, error) { tpl, err := d.cfn.StackMetadata() if err != nil { return nil, err } metadata := struct { Manifest string `yaml:"Manifest"` }{} if err := yaml.Unmarshal([]byte(tpl), &metadata); err != nil { return nil, fmt.Errorf("unmarshal Metadata.Manifest in environment stack: %v", err) } if metadata.Manifest != "" { return []byte(strings.TrimSpace(metadata.Manifest)), nil } // Otherwise, the Manifest wasn't written into the CloudFormation template, we'll convert the config in SSM. mft := manifest.FromEnvConfig(d.env, template.New()) out, err := yaml.Marshal(mft) if err != nil { return nil, fmt.Errorf("marshal manifest generated from SSM: %v", err) } return []byte(strings.TrimSpace(string(out))), nil } // Params returns the parameters of the environment stack. func (d *EnvDescriber) Params() (map[string]string, error) { descr, err := d.cfn.Describe() if err != nil { return nil, err } return descr.Parameters, nil } // Outputs returns the outputs of the environment stack. func (d *EnvDescriber) Outputs() (map[string]string, error) { descr, err := d.cfn.Describe() if err != nil { return nil, err } return descr.Outputs, nil } // AvailableFeatures returns the available features of the environment stack. func (d *EnvDescriber) AvailableFeatures() ([]string, error) { params, err := d.Params() if err != nil { return nil, err } var availableFeatures []string for _, f := range template.AvailableEnvFeatures() { if _, ok := params[f]; ok { availableFeatures = append(availableFeatures, f) } } return availableFeatures, nil } // Version returns the CloudFormation template version associated with // the environment by reading the Metadata.Version field from the template. // // If the Version field does not exist, then it's a legacy template and it returns an version.LegacyEnvTemplate and nil error. func (d *EnvDescriber) Version() (string, error) { return stackVersion(d.cfn, version.LegacyEnvTemplate) } // ServiceDiscoveryEndpoint returns the endpoint the environment was initialized with, if any. Otherwise, // it returns the legacy app.local endpoint. func (d *EnvDescriber) ServiceDiscoveryEndpoint() (string, error) { p, err := d.Params() if err != nil { return "", fmt.Errorf("get params of environment %s in app %s: %w", d.env.Name, d.env.App, err) } for k, v := range p { // Ignore non-svc discovery params if k != cfnstack.EnvParamServiceDiscoveryEndpoint { continue } // Stacks upgraded from legacy environments will have `app.local` as the parameter value. // Stacks created after 1.5.0 will use `env.app.local`. if v != "" { return v, nil } } // If the param does not exist, the environment is legacy, has not been upgraded, and uses `app.local`. return fmt.Sprintf(fmtLegacySvcDiscoveryEndpoint, d.app), nil } // PublicCIDRBlocks returns the public CIDR blocks of the public subnets in the environment VPC. func (d *EnvDescriber) PublicCIDRBlocks() ([]string, error) { _, envVPC, err := d.loadStackInfo() if err != nil { return nil, err } vpcID := envVPC.ID subnets, err := d.subnetLister.ListVPCSubnets(vpcID) if err != nil { return nil, fmt.Errorf("list subnets of vpc %s in environment %s: %w", vpcID, d.env.Name, err) } var cidrBlocks []string for _, subnet := range subnets.Public { cidrBlocks = append(cidrBlocks, subnet.CIDRBlock) } return cidrBlocks, nil } func (d *EnvDescriber) loadStackInfo() (map[string]string, EnvironmentVPC, error) { var environmentVPC EnvironmentVPC envStack, err := d.cfn.Describe() if err != nil { return nil, environmentVPC, fmt.Errorf("retrieve environment stack: %w", err) } for k, v := range envStack.Outputs { switch k { case cfnstack.EnvOutputVPCID: environmentVPC.ID = v case cfnstack.EnvOutputPublicSubnets: environmentVPC.PublicSubnetIDs = strings.Split(v, ",") case cfnstack.EnvOutputPrivateSubnets: environmentVPC.PrivateSubnetIDs = strings.Split(v, ",") } } return envStack.Tags, environmentVPC, nil } func (d *EnvDescriber) filterDeployedSvcs() ([]*config.Workload, error) { allSvcs, err := d.configStore.ListServices(d.app) if err != nil { return nil, fmt.Errorf("list services for app %s: %w", d.app, err) } svcs := make(map[string]*config.Workload) for _, svc := range allSvcs { svcs[svc.Name] = svc } deployedSvcNames, err := d.deployStore.ListDeployedServices(d.app, d.env.Name) if err != nil { return nil, fmt.Errorf("list deployed services in env %s: %w", d.env.Name, err) } var deployedSvcs []*config.Workload for _, deployedSvcName := range deployedSvcNames { deployedSvcs = append(deployedSvcs, svcs[deployedSvcName]) } return deployedSvcs, nil } // filterDeployedJobs lists the jobs that are deployed on the given app and environment func (d *EnvDescriber) filterDeployedJobs() ([]*config.Workload, error) { allJobs, err := d.configStore.ListJobs(d.app) if err != nil { return nil, fmt.Errorf("list jobs for app %s: %w", d.app, err) } jobs := make(map[string]*config.Workload) for _, job := range allJobs { jobs[job.Name] = job } deployedJobNames, err := d.deployStore.ListDeployedJobs(d.app, d.env.Name) if err != nil { return nil, fmt.Errorf("list deployed jobs in env %s: %w", d.env.Name, err) } var deployedJobs []*config.Workload for _, deployedJobName := range deployedJobNames { deployedJobs = append(deployedJobs, jobs[deployedJobName]) } return deployedJobs, nil } // ValidateCFServiceDomainAliases returns error if an environment using cdn is deployed without specifying http.alias for all load-balanced web services func (d *EnvDescriber) ValidateCFServiceDomainAliases() error { stackDescr, err := d.cfn.Describe() if err != nil { return fmt.Errorf("describe stack: %w", err) } servicesString, ok := stackDescr.Parameters[cfnstack.EnvParamALBWorkloadsKey] if !ok || servicesString == "" { return nil } services := strings.Split(servicesString, ",") jsonOutput, ok := stackDescr.Parameters[cfnstack.EnvParamAliasesKey] if !ok { return fmt.Errorf("cannot find %s in env stack parameter set", cfnstack.EnvParamAliasesKey) } var aliases map[string][]string if jsonOutput != "" { err = json.Unmarshal([]byte(jsonOutput), &aliases) if err != nil { return fmt.Errorf("unmarshal %q: %w", jsonOutput, err) } } var lbSvcsWithoutAlias []string for _, service := range services { if _, ok := aliases[service]; !ok { lbSvcsWithoutAlias = append(lbSvcsWithoutAlias, service) } } if len(lbSvcsWithoutAlias) != 0 { return &errLBWebSvcsOnCFWithoutAlias{ services: lbSvcsWithoutAlias, aliasField: "http.alias", } } return nil } // JSONString returns the stringified EnvDescription struct with json format. func (e *EnvDescription) JSONString() (string, error) { b, err := json.Marshal(e) if err != nil { return "", fmt.Errorf("marshal environment description: %w", err) } return fmt.Sprintf("%s\n", b), nil } // HumanString returns the stringified EnvDescription struct with human readable format. func (e *EnvDescription) HumanString() string { var b bytes.Buffer writer := tabwriter.NewWriter(&b, minCellWidth, tabWidth, cellPaddingWidth, paddingChar, noAdditionalFormatting) fmt.Fprint(writer, color.Bold.Sprint("About\n\n")) writer.Flush() fmt.Fprintf(writer, " %s\t%s\n", "Name", e.Environment.Name) fmt.Fprintf(writer, " %s\t%s\n", "Region", e.Environment.Region) fmt.Fprintf(writer, " %s\t%s\n", "Account ID", e.Environment.AccountID) fmt.Fprint(writer, color.Bold.Sprint("\nWorkloads\n\n")) writer.Flush() headers := []string{"Name", "Type"} fmt.Fprintf(writer, " %s\n", strings.Join(headers, "\t")) fmt.Fprintf(writer, " %s\n", strings.Join(underline(headers), "\t")) for _, svc := range e.Services { fmt.Fprintf(writer, " %s\t%s\n", svc.Name, svc.Type) } for _, job := range e.Jobs { fmt.Fprintf(writer, " %s\t%s\n", job.Name, job.Type) } writer.Flush() if len(e.Tags) != 0 { fmt.Fprint(writer, color.Bold.Sprint("\nTags\n\n")) writer.Flush() headers := []string{"Key", "Value"} fmt.Fprintf(writer, " %s\n", strings.Join(headers, "\t")) fmt.Fprintf(writer, " %s\n", strings.Join(underline(headers), "\t")) // sort Tags in alpha order by keys keys := make([]string, 0, len(e.Tags)) for k := range e.Tags { keys = append(keys, k) } sort.Strings(keys) for _, key := range keys { fmt.Fprintf(writer, " %s\t%s\n", key, e.Tags[key]) } } writer.Flush() if len(e.Resources) != 0 { fmt.Fprint(writer, color.Bold.Sprint("\nResources\n\n")) writer.Flush() for _, resource := range e.Resources { fmt.Fprintf(writer, " %s\t%s\n", resource.Type, resource.PhysicalID) } } writer.Flush() return b.String() }