// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package deploy import ( "context" "errors" "fmt" "io" "os" "sort" "strings" "sync" awscfn "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/copilot-cli/internal/pkg/addon" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" awscloudformation "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" "github.com/aws/copilot-cli/internal/pkg/aws/ec2" "github.com/aws/copilot-cli/internal/pkg/aws/elbv2" "github.com/aws/copilot-cli/internal/pkg/aws/partitions" awss3 "github.com/aws/copilot-cli/internal/pkg/aws/s3" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" "github.com/aws/copilot-cli/internal/pkg/cli/deploy/patch" "github.com/aws/copilot-cli/internal/pkg/config" "github.com/aws/copilot-cli/internal/pkg/deploy" deploycfn "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation" cfnstack "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/deploy/upload/customresource" "github.com/aws/copilot-cli/internal/pkg/describe" "github.com/aws/copilot-cli/internal/pkg/describe/stack" "github.com/aws/copilot-cli/internal/pkg/manifest" "github.com/aws/copilot-cli/internal/pkg/override" "github.com/aws/copilot-cli/internal/pkg/template" "github.com/aws/copilot-cli/internal/pkg/template/artifactpath" "github.com/aws/copilot-cli/internal/pkg/template/diff" "github.com/aws/copilot-cli/internal/pkg/term/log" termprogress "github.com/aws/copilot-cli/internal/pkg/term/progress" "github.com/spf13/afero" "golang.org/x/sync/errgroup" ) // WorkspaceAddonsReaderPathGetter reads addons from a workspace and the path of a workspace. type WorkspaceAddonsReaderPathGetter interface { addon.WorkspaceAddonsReader Path() string } type appResourcesGetter interface { GetAppResourcesByRegion(app *config.Application, region string) (*cfnstack.AppRegionalResources, error) } type environmentDeployer interface { UpdateAndRenderEnvironment(conf deploycfn.StackConfiguration, bucketARN string, opts ...cloudformation.StackOption) error DeployedEnvironmentParameters(app, env string) ([]*awscfn.Parameter, error) ForceUpdateOutputID(app, env string) (string, error) } type patcher interface { EnsureManagerRoleIsAllowedToUpload(bucketName string) error } type prefixListGetter interface { CloudFrontManagedPrefixListID() (string, error) } type envDescriber interface { ValidateCFServiceDomainAliases() error Params() (map[string]string, error) } type lbDescriber interface { DescribeRule(context.Context, string) (elbv2.Rule, error) } type stackDescriber interface { Resources() ([]*stack.Resource, error) } type addons struct { stack stackBuilder err error } type envDeployer struct { app *config.Application env *config.Environment // Dependencies to upload artifacts. templateFS template.Reader s3 uploader prefixListGetter prefixListGetter // Dependencies to deploy an environment. appCFN appResourcesGetter envDeployer environmentDeployer tmplGetter deployedTemplateGetter patcher patcher newStack func(input *cfnstack.EnvConfig, forceUpdateID string, prevParams []*awscfn.Parameter) (deploycfn.StackConfiguration, error) envDescriber envDescriber lbDescriber lbDescriber newServiceStackDescriber func(string) stackDescriber // Dependencies for parsing addons. ws WorkspaceAddonsReaderPathGetter parseAddonsOnce sync.Once parseAddons func() (stackBuilder, error) // Cached variables. appRegionalResources *cfnstack.AppRegionalResources addons addons } // NewEnvDeployerInput contains information needed to construct an environment deployer. type NewEnvDeployerInput struct { App *config.Application Env *config.Environment SessionProvider *sessions.Provider ConfigStore describe.ConfigStoreSvc Workspace WorkspaceAddonsReaderPathGetter Overrider Overrider } // NewEnvDeployer constructs an environment deployer. func NewEnvDeployer(in *NewEnvDeployerInput) (*envDeployer, error) { defaultSession, err := in.SessionProvider.Default() if err != nil { return nil, fmt.Errorf("get default session: %w", err) } envRegionSession, err := in.SessionProvider.DefaultWithRegion(in.Env.Region) if err != nil { return nil, fmt.Errorf("get default session in env region %s: %w", in.Env.Region, err) } envManagerSession, err := in.SessionProvider.FromRole(in.Env.ManagerRoleARN, in.Env.Region) if err != nil { return nil, fmt.Errorf("get env session: %w", err) } envDescriber, err := describe.NewEnvDescriber(describe.NewEnvDescriberConfig{ App: in.App.Name, Env: in.Env.Name, ConfigStore: in.ConfigStore, }) if err != nil { return nil, fmt.Errorf("initialize env describer: %w", err) } overrider := in.Overrider if overrider == nil { overrider = new(override.Noop) } cfnClient := deploycfn.New(envManagerSession, deploycfn.WithProgressTracker(os.Stderr)) deployer := &envDeployer{ app: in.App, env: in.Env, templateFS: template.New(), s3: awss3.New(envManagerSession), prefixListGetter: ec2.New(envRegionSession), appCFN: deploycfn.New(defaultSession, deploycfn.WithProgressTracker(os.Stderr)), envDeployer: cfnClient, tmplGetter: cfnClient, patcher: &patch.EnvironmentPatcher{ Prog: termprogress.NewSpinner(log.DiagnosticWriter), TemplatePatcher: cfnClient, Env: in.Env, }, newStack: func(in *cfnstack.EnvConfig, lastForceUpdateID string, oldParams []*awscfn.Parameter) (deploycfn.StackConfiguration, error) { stack, err := cfnstack.NewEnvConfigFromExistingStack(in, lastForceUpdateID, oldParams) if err != nil { return nil, err } return deploycfn.WrapWithTemplateOverrider(stack, overrider), nil }, envDescriber: envDescriber, lbDescriber: elbv2.New(envManagerSession), newServiceStackDescriber: func(svc string) stackDescriber { return stack.NewStackDescriber(cfnstack.NameForWorkload(in.App.Name, in.Env.Name, svc), envManagerSession) }, ws: in.Workspace, } deployer.parseAddons = func() (stackBuilder, error) { deployer.parseAddonsOnce.Do(func() { deployer.addons.stack, deployer.addons.err = addon.ParseFromEnv(deployer.ws) }) return deployer.addons.stack, deployer.addons.err } return deployer, nil } // Validate returns an error if the environment manifest is incompatible with services and application configurations. func (d *envDeployer) Validate(mft *manifest.Environment) error { return d.validateCDN(mft) } // UploadEnvArtifactsOutput holds URLs of artifacts pushed to S3 buckets. type UploadEnvArtifactsOutput struct { AddonsURL string CustomResourceURLs map[string]string } // UploadArtifacts uploads the deployment artifacts for the environment. func (d *envDeployer) UploadArtifacts() (*UploadEnvArtifactsOutput, error) { resources, err := d.getAppRegionalResources() if err != nil { return nil, err } if err := d.patcher.EnsureManagerRoleIsAllowedToUpload(resources.S3Bucket); err != nil { return nil, fmt.Errorf("ensure env manager role has permissions to upload: %w", err) } customResourceURLs, err := d.uploadCustomResources(resources.S3Bucket) if err != nil { return nil, err } addonsURL, err := d.uploadAddons(resources.S3Bucket) if err != nil { return nil, err } return &UploadEnvArtifactsOutput{ AddonsURL: addonsURL, CustomResourceURLs: customResourceURLs, }, nil } // DeployDiff returns the stringified diff of the template against the deployed template of the environment. func (d *envDeployer) DeployDiff(template string) (string, error) { tmpl, err := d.tmplGetter.Template(cfnstack.NameForEnv(d.app.Name, d.env.Name)) if err != nil { var errNotFound *awscloudformation.ErrStackNotFound if !errors.As(err, &errNotFound) { return "", fmt.Errorf("retrieve the deployed template for %q: %w", d.env.Name, err) } tmpl = "" } diffTree, err := diff.From(tmpl).ParseWithCFNOverriders([]byte(template)) if err != nil { return "", fmt.Errorf("parse the diff against the deployed env stack %q: %w", d.env.Name, err) } buf := strings.Builder{} if err := diffTree.Write(&buf); err != nil { return "", err } return buf.String(), nil } // AddonsTemplate returns the environment addons template. func (d *envDeployer) AddonsTemplate() (string, error) { addons, err := d.parseAddons() if err != nil { var notFoundErr *addon.ErrAddonsNotFound if !errors.As(err, ¬FoundErr) { return "", fmt.Errorf("parse environment addons: %w", err) } return "", nil } tmpl, err := addons.Template() if err != nil { return "", fmt.Errorf("render addons template: %w", err) } return tmpl, nil } // DeployEnvironmentInput contains information used to deploy the environment. type DeployEnvironmentInput struct { RootUserARN string AddonsURL string CustomResourcesURLs map[string]string Manifest *manifest.Environment ForceNewUpdate bool RawManifest []byte PermissionsBoundary string DisableRollback bool Version string } // GenerateCloudFormationTemplate returns the environment stack's template and parameter configuration. func (d *envDeployer) GenerateCloudFormationTemplate(in *DeployEnvironmentInput) (*GenerateCloudFormationTemplateOutput, error) { stackInput, err := d.buildStackInput(in) if err != nil { return nil, err } oldParams, err := d.envDeployer.DeployedEnvironmentParameters(d.app.Name, d.env.Name) if err != nil { return nil, fmt.Errorf("describe environment stack parameters: %w", err) } lastForceUpdateID, err := d.envDeployer.ForceUpdateOutputID(d.app.Name, d.env.Name) if err != nil { return nil, fmt.Errorf("retrieve environment stack force update ID: %w", err) } stack, err := d.newStack(stackInput, lastForceUpdateID, oldParams) if err != nil { return nil, err } tpl, err := stack.Template() if err != nil { return nil, fmt.Errorf("generate stack template: %w", err) } params, err := stack.SerializedParameters() if err != nil { return nil, fmt.Errorf("generate stack template parameters: %w", err) } return &GenerateCloudFormationTemplateOutput{ Template: tpl, Parameters: params, }, nil } // DeployEnvironment deploys an environment using CloudFormation. func (d *envDeployer) DeployEnvironment(in *DeployEnvironmentInput) error { stackInput, err := d.buildStackInput(in) if err != nil { return err } oldParams, err := d.envDeployer.DeployedEnvironmentParameters(d.app.Name, d.env.Name) if err != nil { return fmt.Errorf("describe environment stack parameters: %w", err) } lastForceUpdateID, err := d.envDeployer.ForceUpdateOutputID(d.app.Name, d.env.Name) if err != nil { return fmt.Errorf("retrieve environment stack force update ID: %w", err) } opts := []awscloudformation.StackOption{ awscloudformation.WithRoleARN(d.env.ExecutionRoleARN), } if in.DisableRollback { opts = append(opts, awscloudformation.WithDisableRollback()) } stack, err := d.newStack(stackInput, lastForceUpdateID, oldParams) if err != nil { return err } return d.envDeployer.UpdateAndRenderEnvironment(stack, stackInput.ArtifactBucketARN, opts...) } func (d *envDeployer) getAppRegionalResources() (*cfnstack.AppRegionalResources, error) { if d.appRegionalResources != nil { return d.appRegionalResources, nil } resources, err := d.appCFN.GetAppResourcesByRegion(d.app, d.env.Region) if err != nil { return nil, fmt.Errorf("get app resources in region %s: %w", d.env.Region, err) } if resources.S3Bucket == "" { return nil, fmt.Errorf("cannot find the S3 artifact bucket in region %s", d.env.Region) } return resources, nil } func (d *envDeployer) uploadCustomResources(bucket string) (map[string]string, error) { crs, err := customresource.Env(d.templateFS) if err != nil { return nil, fmt.Errorf("read custom resources for environment %s: %w", d.env.Name, err) } urls, err := customresource.Upload(func(key string, dat io.Reader) (url string, err error) { return d.s3.Upload(bucket, key, dat) }, crs) if err != nil { return nil, fmt.Errorf("upload custom resources to bucket %s: %w", bucket, err) } return urls, nil } func (d *envDeployer) uploadAddons(bucket string) (string, error) { addons, err := d.parseAddons() if err != nil { var notFoundErr *addon.ErrAddonsNotFound if !errors.As(err, ¬FoundErr) { return "", fmt.Errorf("parse environment addons: %w", err) } return "", nil } pkgConfig := addon.PackageConfig{ Bucket: bucket, Uploader: d.s3, WorkspacePath: d.ws.Path(), FS: afero.NewOsFs(), } if err := addons.Package(pkgConfig); err != nil { return "", fmt.Errorf("package environment addons: %w", err) } tmpl, err := addons.Template() if err != nil { return "", fmt.Errorf("render addons template: %w", err) } url, err := d.s3.Upload(bucket, artifactpath.EnvironmentAddons([]byte(tmpl)), strings.NewReader(tmpl)) if err != nil { return "", fmt.Errorf("upload addons template to bucket %s: %w", bucket, err) } return url, nil } func (d *envDeployer) buildStackInput(in *DeployEnvironmentInput) (*cfnstack.EnvConfig, error) { resources, err := d.getAppRegionalResources() if err != nil { return nil, err } partition, err := partitions.Region(d.env.Region).Partition() if err != nil { return nil, err } cidrPrefixListIDs, err := d.cidrPrefixLists(in) if err != nil { return nil, err } addons, err := d.buildAddonsInput(resources.Region, resources.S3Bucket, in.AddonsURL) if err != nil { return nil, err } return &cfnstack.EnvConfig{ Name: d.env.Name, App: deploy.AppInformation{ Name: d.app.Name, Domain: d.app.Domain, AccountPrincipalARN: in.RootUserARN, }, AdditionalTags: d.app.Tags, Addons: addons, CustomResourcesURLs: in.CustomResourcesURLs, ArtifactBucketARN: awss3.FormatARN(partition.ID(), resources.S3Bucket), ArtifactBucketKeyARN: resources.KMSKeyARN, CIDRPrefixListIDs: cidrPrefixListIDs, PublicALBSourceIPs: d.publicALBSourceIPs(in), Mft: in.Manifest, ForceUpdate: in.ForceNewUpdate, RawMft: in.RawManifest, PermissionsBoundary: in.PermissionsBoundary, Version: in.Version, }, nil } func (d *envDeployer) buildAddonsInput(region, bucket, uploadURL string) (*cfnstack.Addons, error) { parsedAddons, err := d.parseAddons() if err != nil { var notFoundErr *addon.ErrAddonsNotFound if errors.As(err, ¬FoundErr) { return nil, nil } return nil, err } if uploadURL != "" { return &cfnstack.Addons{ S3ObjectURL: uploadURL, Stack: parsedAddons, }, nil } tpl, err := parsedAddons.Template() if err != nil { return nil, fmt.Errorf("render addons template: %w", err) } return &cfnstack.Addons{ S3ObjectURL: awss3.URL(region, bucket, artifactpath.EnvironmentAddons([]byte(tpl))), Stack: parsedAddons, }, nil } // lbServiceRedirects returns true if svc's HTTP listener rule redirects. We only check // HTTPListenerRuleWithDomain because HTTPListenerRule doesn't ever redirect. func (d *envDeployer) lbServiceRedirects(ctx context.Context, svc string) (bool, error) { stackDescriber := d.newServiceStackDescriber(svc) resources, err := stackDescriber.Resources() if err != nil { return false, fmt.Errorf("get stack resources: %w", err) } var ruleARN string for _, res := range resources { if res.LogicalID == template.LogicalIDHTTPListenerRuleWithDomain { ruleARN = res.PhysicalID break } } if ruleARN == "" { // this will happen if the service doesn't support https. return false, nil } rule, err := d.lbDescriber.DescribeRule(ctx, ruleARN) if err != nil { return false, fmt.Errorf("describe listener rule %q: %w", ruleARN, err) } return rule.HasRedirectAction(), nil } func (d *envDeployer) validateCDN(mft *manifest.Environment) error { isManagedCDNEnabled := mft.CDNEnabled() && !mft.HasImportedPublicALBCerts() && d.app.Domain != "" if isManagedCDNEnabled { // With managed domain, if the customer isn't using `alias` the A-records are inserted in the service stack as each service domain is unique. // However, when clients enable CloudFront, they would need to update all their existing records to now point to the distribution. // Hence, we force users to use `alias` and let the records be written in the environment stack instead. if err := d.envDescriber.ValidateCFServiceDomainAliases(); err != nil { return err } } if mft.CDNEnabled() && mft.CDNDoesTLSTermination() { err := d.validateALBWorkloadsDontRedirect() var redirErr *errEnvHasPublicServicesWithRedirect switch { case errors.As(err, &redirErr) && mft.IsPublicLBIngressRestrictedToCDN(): return err case errors.As(err, &redirErr): log.Warningln(redirErr.warning()) case err != nil: return fmt.Errorf("enable TLS termination on CDN: %w", err) } } return nil } // validateALBWorkloadsDontRedirect verifies that none of the public ALB Workloads // in this environment have a redirect in their HTTPWithDomain listener. // If any services redirect, an error is returned. func (d *envDeployer) validateALBWorkloadsDontRedirect() error { params, err := d.envDescriber.Params() if err != nil { return fmt.Errorf("get env params: %w", err) } if params[cfnstack.EnvParamALBWorkloadsKey] == "" { return nil } services := strings.Split(params[cfnstack.EnvParamALBWorkloadsKey], ",") g, ctx := errgroup.WithContext(context.Background()) var badServices []string var badServicesMu sync.Mutex for i := range services { svc := services[i] g.Go(func() error { redirects, err := d.lbServiceRedirects(ctx, svc) switch { case err != nil: return fmt.Errorf("verify service %q: %w", svc, err) case redirects: badServicesMu.Lock() defer badServicesMu.Unlock() badServices = append(badServices, svc) } return nil }) } if err := g.Wait(); err != nil { return err } if len(badServices) > 0 { sort.Strings(badServices) return &errEnvHasPublicServicesWithRedirect{ services: badServices, } } return nil } func (d *envDeployer) cidrPrefixLists(in *DeployEnvironmentInput) ([]string, error) { var cidrPrefixListIDs []string // Check if ingress is allowed from cloudfront if in.Manifest == nil || !in.Manifest.IsPublicLBIngressRestrictedToCDN() { return nil, nil } cfManagedPrefixListID, err := d.cfManagedPrefixListID() if err != nil { return nil, err } cidrPrefixListIDs = append(cidrPrefixListIDs, cfManagedPrefixListID) return cidrPrefixListIDs, nil } func (d *envDeployer) publicALBSourceIPs(in *DeployEnvironmentInput) []string { if in.Manifest == nil || len(in.Manifest.GetPublicALBSourceIPs()) == 0 { return nil } ips := make([]string, len(in.Manifest.GetPublicALBSourceIPs())) for i, sourceIP := range in.Manifest.GetPublicALBSourceIPs() { ips[i] = string(sourceIP) } return ips } func (d *envDeployer) cfManagedPrefixListID() (string, error) { id, err := d.prefixListGetter.CloudFrontManagedPrefixListID() if err != nil { return "", fmt.Errorf("retrieve CloudFront managed prefix list id: %w", err) } return id, nil }