internal/pkg/cli/svc_deploy.go (662 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cli import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" awscfn "github.com/aws/copilot-cli/internal/pkg/aws/cloudformation" "github.com/aws/copilot-cli/internal/pkg/aws/identity" "github.com/aws/copilot-cli/internal/pkg/aws/tags" deploycfn "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation" "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" "github.com/aws/copilot-cli/internal/pkg/manifest/manifestinfo" "github.com/aws/copilot-cli/internal/pkg/template" "github.com/aws/copilot-cli/internal/pkg/version" "github.com/spf13/afero" "golang.org/x/mod/semver" "github.com/spf13/cobra" "github.com/aws/copilot-cli/internal/pkg/aws/sessions" clideploy "github.com/aws/copilot-cli/internal/pkg/cli/deploy" "github.com/aws/copilot-cli/internal/pkg/config" "github.com/aws/copilot-cli/internal/pkg/describe" "github.com/aws/copilot-cli/internal/pkg/exec" "github.com/aws/copilot-cli/internal/pkg/manifest" "github.com/aws/copilot-cli/internal/pkg/term/color" "github.com/aws/copilot-cli/internal/pkg/term/log" termprogress "github.com/aws/copilot-cli/internal/pkg/term/progress" "github.com/aws/copilot-cli/internal/pkg/term/prompt" "github.com/aws/copilot-cli/internal/pkg/term/selector" "github.com/aws/copilot-cli/internal/pkg/workspace" ) type deployWkldVars struct { appName string name string envName string imageTag string resourceTags map[string]string forceNewUpdate bool // NOTE: this variable is not applicable for a job workload currently. disableRollback bool showDiff bool skipDiffPrompt bool allowWkldDowngrade bool detach bool // To facilitate unit tests. clientConfigured bool } type deploySvcOpts struct { deployWkldVars store store ws wsWlDirReader unmarshal func([]byte) (manifest.DynamicWorkload, error) newInterpolator func(app, env string) interpolator cmd execRunner sessProvider *sessions.Provider newSvcDeployer func() (workloadDeployer, error) svcVersionGetter versionGetter envFeaturesDescriber versionCompatibilityChecker diffWriter io.Writer spinner progress sel wsSelector prompt prompter gitShortCommit string // cached variables targetApp *config.Application targetEnv *config.Environment envSess *session.Session svcType string appliedDynamicMft manifest.DynamicWorkload rawMft string rootUserARN string deployRecs clideploy.ActionRecommender noDeploy bool // Overridden in tests. templateVersion string } func newSvcDeployOpts(vars deployWkldVars) (*deploySvcOpts, error) { ws, err := workspace.Use(afero.NewOsFs()) if err != nil { return nil, err } sessProvider := sessions.ImmutableProvider(sessions.UserAgentExtras("svc deploy")) defaultSession, err := sessProvider.Default() if err != nil { return nil, err } store := config.NewSSMStore(identity.New(defaultSession), ssm.New(defaultSession), aws.StringValue(defaultSession.Config.Region)) prompter := prompt.New() opts := &deploySvcOpts{ deployWkldVars: vars, store: store, ws: ws, unmarshal: manifest.UnmarshalWorkload, spinner: termprogress.NewSpinner(log.DiagnosticWriter), sel: selector.NewLocalWorkloadSelector(prompter, store, ws, selector.OnlyInitializedWorkloads), prompt: prompter, newInterpolator: newManifestInterpolator, cmd: exec.NewCmd(), sessProvider: sessProvider, diffWriter: os.Stdout, templateVersion: version.LatestTemplateVersion(), } opts.newSvcDeployer = func() (workloadDeployer, error) { // NOTE: Defined as a struct member to facilitate unit testing. return newSvcDeployer(opts) } return opts, err } func newSvcDeployer(o *deploySvcOpts) (workloadDeployer, error) { targetApp, err := o.getTargetApp() if err != nil { return nil, err } ovrdr, err := clideploy.NewOverrider(o.ws.WorkloadOverridesPath(o.name), o.appName, o.envName, afero.NewOsFs(), o.sessProvider) if err != nil { return nil, err } content := o.appliedDynamicMft.Manifest() var deployer workloadDeployer in := clideploy.WorkloadDeployerInput{ SessionProvider: o.sessProvider, Name: o.name, App: targetApp, Env: o.targetEnv, Image: clideploy.ContainerImageIdentifier{ CustomTag: o.imageTag, GitShortCommitTag: o.gitShortCommit, }, Mft: content, RawMft: o.rawMft, EnvVersionGetter: o.envFeaturesDescriber, Overrider: ovrdr, } switch t := content.(type) { case *manifest.LoadBalancedWebService: deployer, err = clideploy.NewLBWSDeployer(&in) case *manifest.BackendService: deployer, err = clideploy.NewBackendDeployer(&in) case *manifest.RequestDrivenWebService: deployer, err = clideploy.NewRDWSDeployer(&in) case *manifest.WorkerService: deployer, err = clideploy.NewWorkerSvcDeployer(&in) case *manifest.StaticSite: deployer, err = clideploy.NewStaticSiteDeployer(&in) default: return nil, fmt.Errorf("unknown manifest type %T while creating the CloudFormation stack", t) } if err != nil { return nil, fmt.Errorf("initiate workload deployer: %w", err) } return deployer, nil } func newManifestInterpolator(app, env string) interpolator { return manifest.NewInterpolator(app, env) } // Validate returns an error for any invalid optional flags. func (o *deploySvcOpts) Validate() error { return nil } // Ask prompts for and validates any required flags. func (o *deploySvcOpts) Ask() error { if o.appName != "" { if _, err := o.getTargetApp(); err != nil { return err } } else { // NOTE: This command is required to be executed under a workspace. We don't prompt for it. return errNoAppInWorkspace } if err := o.validateOrAskSvcName(); err != nil { return err } if err := o.validateOrAskEnvName(); err != nil { return err } return nil } // Execute builds and pushes the container image for the service, func (o *deploySvcOpts) Execute() error { if !o.clientConfigured { if err := o.configureClients(); err != nil { return err } } if !o.allowWkldDowngrade { if err := validateWkldVersion(o.svcVersionGetter, o.name, o.templateVersion); err != nil { return err } } mft, interpolated, err := workloadManifest(&workloadManifestInput{ name: o.name, appName: o.appName, envName: o.envName, ws: o.ws, interpolator: o.newInterpolator(o.appName, o.envName), unmarshal: o.unmarshal, sess: o.envSess, }) if err != nil { return err } o.rawMft = interpolated o.appliedDynamicMft = mft if o.forceNewUpdate && o.svcType == manifestinfo.StaticSiteType { return fmt.Errorf("--%s is not supported for service type %q", forceFlag, manifestinfo.StaticSiteType) } if err := validateWorkloadManifestCompatibilityWithEnv(o.ws, o.envFeaturesDescriber, mft, o.envName); err != nil { return err } deployer, err := o.newSvcDeployer() if err != nil { return err } serviceInRegion, err := deployer.IsServiceAvailableInRegion(o.targetEnv.Region) if err != nil { return fmt.Errorf("check if %s is available in region %s: %w", o.svcType, o.targetEnv.Region, err) } if !serviceInRegion { log.Warningf(`%s might not be available in region %s; proceed with caution. `, o.svcType, o.targetEnv.Region) } uploadOut, err := deployer.UploadArtifacts() if err != nil { return fmt.Errorf("upload deploy resources for service %s: %w", o.name, err) } targetApp, err := o.getTargetApp() if err != nil { return err } if o.showDiff { output, err := deployer.GenerateCloudFormationTemplate(&clideploy.GenerateCloudFormationTemplateInput{ StackRuntimeConfiguration: clideploy.StackRuntimeConfiguration{ RootUserARN: o.rootUserARN, Tags: targetApp.Tags, EnvFileARNs: uploadOut.EnvFileARNs, ImageDigests: uploadOut.ImageDigests, AddonsURL: uploadOut.AddonsURL, CustomResourceURLs: uploadOut.CustomResourceURLs, StaticSiteAssetMappingURL: uploadOut.StaticSiteAssetMappingLocation, Version: o.templateVersion, }, }) if err != nil { return fmt.Errorf("generate the template for workload %q against environment %q: %w", o.name, o.envName, err) } if err := diff(deployer, output.Template, o.diffWriter); err != nil { var errHasDiff *errHasDiff if !errors.As(err, &errHasDiff) { return err } } contd, err := o.skipDiffPrompt, nil if !o.skipDiffPrompt { contd, err = o.prompt.Confirm(continueDeploymentPrompt, "") } if err != nil { return fmt.Errorf("ask whether to continue with the deployment: %w", err) } if !contd { o.noDeploy = true return nil } } deployRecs, err := deployer.DeployWorkload(&clideploy.DeployWorkloadInput{ StackRuntimeConfiguration: clideploy.StackRuntimeConfiguration{ ImageDigests: uploadOut.ImageDigests, EnvFileARNs: uploadOut.EnvFileARNs, AddonsURL: uploadOut.AddonsURL, RootUserARN: o.rootUserARN, Tags: tags.Merge(targetApp.Tags, o.resourceTags), CustomResourceURLs: uploadOut.CustomResourceURLs, StaticSiteAssetMappingURL: uploadOut.StaticSiteAssetMappingLocation, Version: o.templateVersion, }, Options: clideploy.Options{ ForceNewUpdate: o.forceNewUpdate, DisableRollback: o.disableRollback, Detach: o.detach, }, }) if err != nil { var errStackDeletedOnInterrupt *deploycfn.ErrStackDeletedOnInterrupt var errStackUpdateCanceledOnInterrupt *deploycfn.ErrStackUpdateCanceledOnInterrupt var errEmptyChangeSet *awscfn.ErrChangeSetEmpty if errors.As(err, &errStackDeletedOnInterrupt) { o.noDeploy = true return nil } if errors.As(err, &errStackUpdateCanceledOnInterrupt) { log.Successf("Successfully rolled back service %s to the previous configuration.\n", color.HighlightUserInput(o.name)) o.noDeploy = true return nil } if o.disableRollback { stackName := stack.NameForWorkload(o.targetApp.Name, o.targetEnv.Name, o.name) rollbackCmd := fmt.Sprintf("aws cloudformation rollback-stack --stack-name %s --role-arn %s", stackName, o.targetEnv.ExecutionRoleARN) log.Infof(`It seems like you have disabled automatic stack rollback for this deployment. To debug, you can: * Run %s to inspect the service log. * Visit the AWS console to inspect the errors. After fixing the deployment, you can: 1. Run %s to rollback the deployment. 2. Run %s to make a new deployment. `, color.HighlightCode("copilot svc logs"), color.HighlightCode(rollbackCmd), color.HighlightCode("copilot svc deploy")) } if errors.As(err, &errEmptyChangeSet) { return &errNoInfrastructureChanges{parentErr: err} } return fmt.Errorf("deploy service %s to environment %s: %w", o.name, o.envName, err) } if o.detach { return nil } log.Successf("Deployed service %s.\n", color.HighlightUserInput(o.name)) o.deployRecs = deployRecs return nil } // RecommendActions returns follow-up actions the user can take after successfully executing the command. func (o *deploySvcOpts) RecommendActions() error { if lbMft, ok := o.appliedDynamicMft.Manifest().(*manifest.LoadBalancedWebService); ok { if !lbMft.NLBConfig.IsEmpty() { log.Warning("With v1.33.0, Copilot applies a security group to your network load balancer. ", "This allows more fine-grained intra-VPC access control: ", "your service won't need to allow-list the CIDR blocks of the public subnets where the NLB is deployed; ", "it only needs to allow-list the NLB, specifically.\n", "\n", "NLB security group onboarding implies resource recreation, ", "because a security group can't be added to an existing NLB that does not already have one. ", "Therefore, you may see some resource recreation related to your NLB. ", "This means:\n", "1. If you don't use DNS aliases, then the NLB's domain name will change.\n", "2. If you use DNS aliases, then the aliases will start pointing to the new NLB that is enhanced with a security group.\n", "\n", "For more on NLB security groups, please see https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-security-groups.html.\n") } } if o.noDeploy || o.detach { return nil } var recommendations []string uriRecs, err := o.uriRecommendedActions() if err != nil { return err } recommendations = append(recommendations, uriRecs...) recommendations = append(recommendations, o.deployRecs.RecommendedActions()...) recommendations = append(recommendations, o.publishRecommendedActions()...) logRecommendedActions(recommendations) return nil } func (o *deploySvcOpts) validateSvcName() error { names, err := o.ws.ListServices() if err != nil { return fmt.Errorf("list services in the workspace: %w", err) } for _, name := range names { if o.name == name { return nil } } return fmt.Errorf("service %s not found in the workspace", color.HighlightUserInput(o.name)) } func (o *deploySvcOpts) validateEnvName() error { if _, err := o.store.GetEnvironment(o.appName, o.envName); err != nil { return fmt.Errorf("get environment %s configuration: %w", o.envName, err) } return nil } func (o *deploySvcOpts) validateOrAskSvcName() error { if o.name != "" { if err := o.validateSvcName(); err != nil { return err } } else { name, err := o.sel.Service("Select a service in your workspace", "") if err != nil { return fmt.Errorf("select service: %w", err) } o.name = name } svc, err := o.store.GetService(o.appName, o.name) if err != nil { return fmt.Errorf("get service %s configuration: %w", o.name, err) } o.svcType = svc.Type return nil } func (o *deploySvcOpts) validateOrAskEnvName() error { if o.envName != "" { return o.validateEnvName() } name, err := o.sel.Environment("Select an environment", "", o.appName) if err != nil { return fmt.Errorf("select environment: %w", err) } o.envName = name return nil } func (o *deploySvcOpts) configureClients() error { o.gitShortCommit = imageTagFromGit(o.cmd) // Best effort assign git tag. env, err := o.store.GetEnvironment(o.appName, o.envName) if err != nil { return fmt.Errorf("get environment %s configuration: %w", o.envName, err) } o.targetEnv = env // client to retrieve an application's resources created with CloudFormation. defaultSess, err := o.sessProvider.Default() if err != nil { return fmt.Errorf("create default session: %w", err) } envSess, err := o.sessProvider.FromRole(env.ManagerRoleARN, env.Region) if err != nil { return err } o.envSess = envSess // client to retrieve caller identity. caller, err := identity.New(defaultSess).Get() if err != nil { return fmt.Errorf("get identity: %w", err) } o.rootUserARN = caller.RootUserARN envDescriber, err := describe.NewEnvDescriber(describe.NewEnvDescriberConfig{ App: o.appName, Env: o.envName, ConfigStore: o.store, }) if err != nil { return err } o.envFeaturesDescriber = envDescriber wkldDescriber, err := describe.NewWorkloadStackDescriber(describe.NewWorkloadConfig{ App: o.appName, Env: o.envName, Name: o.name, ConfigStore: o.store, }) if err != nil { return err } o.svcVersionGetter = wkldDescriber return nil } type workloadManifestInput struct { name string appName string envName string ws wsWlDirReader interpolator interpolator sess *session.Session unmarshal func([]byte) (manifest.DynamicWorkload, error) } func workloadManifest(in *workloadManifestInput) (manifest.DynamicWorkload, string, error) { raw, err := in.ws.ReadWorkloadManifest(in.name) if err != nil { return nil, "", fmt.Errorf("read manifest file for %s: %w", in.name, err) } interpolated, err := in.interpolator.Interpolate(string(raw)) if err != nil { return nil, "", fmt.Errorf("interpolate environment variables for %s manifest: %w", in.name, err) } mft, err := in.unmarshal([]byte(interpolated)) if err != nil { return nil, "", fmt.Errorf("unmarshal service %s manifest: %w", in.name, err) } envMft, err := mft.ApplyEnv(in.envName) if err != nil { return nil, "", fmt.Errorf("apply environment %s override: %w", in.envName, err) } if err := envMft.Validate(); err != nil { return nil, "", fmt.Errorf("validate manifest against environment %q: %w", in.envName, err) } if err := envMft.Load(in.sess); err != nil { return nil, "", fmt.Errorf("load dynamic content: %w", err) } return envMft, interpolated, nil } func validateWorkloadManifestCompatibilityWithEnv(ws wsEnvironmentsLister, env versionCompatibilityChecker, mft manifest.DynamicWorkload, envName string) error { currVersion, err := env.Version() if err != nil { return fmt.Errorf("get environment %q version: %w", envName, err) } if currVersion == version.EnvTemplateBootstrap { return fmt.Errorf(`cannot deploy a service to an undeployed environment. Please run "copilot env deploy --name %s" to deploy the environment first`, envName) } availableFeatures, err := env.AvailableFeatures() if err != nil { return fmt.Errorf("get available features of the %s environment stack: %w", envName, err) } exists := struct{}{} available := make(map[string]struct{}) for _, f := range availableFeatures { available[f] = exists } features := mft.RequiredEnvironmentFeatures() for _, f := range features { if _, ok := available[f]; !ok { logMsg := fmt.Sprintf(`Your manifest configuration requires your environment %q to have the feature %q available.`, envName, template.FriendlyEnvFeatureName(f)) if v := template.LeastVersionForFeature(f); v != "" { logMsg += fmt.Sprintf(` The least environment version that supports the feature is %s.`, v) } logMsg += fmt.Sprintf(" Your environment is on %s.", currVersion) log.Errorln(logMsg) return &errFeatureIncompatibleWithEnvironment{ ws: ws, missingFeature: f, envName: envName, curVersion: currVersion, } } } return nil } func validateWkldVersion(vg versionGetter, name, templateVersion string) error { svcVersion, err := vg.Version() if err != nil { var errStackNotExist *cloudformation.ErrStackNotFound if errors.As(err, &errStackNotExist) { return nil } return fmt.Errorf("get template version of workload %s: %w", name, err) } if diff := semver.Compare(svcVersion, templateVersion); diff > 0 { return &errCannotDowngradeWkldVersion{ name: name, version: svcVersion, templateVersion: templateVersion, } } return nil } func (o *deploySvcOpts) uriRecommendedActions() ([]string, error) { describer, err := describe.NewReachableService(o.appName, o.name, o.store) if err != nil { var errNotAccessible *describe.ErrNonAccessibleServiceType if errors.As(err, &errNotAccessible) { return nil, nil } return nil, err } uri, err := describer.URI(o.envName) if err != nil { return nil, fmt.Errorf("get uri for environment %s: %w", o.envName, err) } network := "over the internet." switch uri.AccessType { case describe.URIAccessTypeInternal: network = "from your internal network." case describe.URIAccessTypeServiceDiscovery: network = "with service discovery." case describe.URIAccessTypeServiceConnect: network = "with Service Connect." case describe.URIAccessTypeNone: return []string{}, nil } return []string{ fmt.Sprintf("Your service is accessible at %s %s", uri.URI, network), }, nil } func (o *deploySvcOpts) publishRecommendedActions() []string { type publisher interface { Publish() []manifest.Topic } mft, ok := o.appliedDynamicMft.Manifest().(publisher) if !ok { return nil } if topics := mft.Publish(); len(topics) == 0 { return nil } return []string{ fmt.Sprintf(`Update %s's code to leverage the injected environment variable "COPILOT_SNS_TOPIC_ARNS". In JavaScript you can write %s.`, o.name, color.HighlightCode("const {<topicName>} = JSON.parse(process.env.COPILOT_SNS_TOPIC_ARNS)")), } } func (o *deploySvcOpts) getTargetApp() (*config.Application, error) { if o.targetApp != nil { return o.targetApp, nil } app, err := o.store.GetApplication(o.appName) if err != nil { return nil, fmt.Errorf("get application %s configuration: %w", o.appName, err) } o.targetApp = app return o.targetApp, nil } type errFeatureIncompatibleWithEnvironment struct { ws wsEnvironmentsLister missingFeature string envName string curVersion string } func (e *errFeatureIncompatibleWithEnvironment) Error() string { if e.curVersion == "" { return fmt.Sprintf("environment %q is not on a version that supports the %q feature", e.envName, template.FriendlyEnvFeatureName(e.missingFeature)) } return fmt.Sprintf("environment %q is on version %q which does not support the %q feature", e.envName, e.curVersion, template.FriendlyEnvFeatureName(e.missingFeature)) } // RecommendActions returns recommended actions to be taken after the error. // Implements main.actionRecommender interface. func (e *errFeatureIncompatibleWithEnvironment) RecommendActions() string { envs, _ := e.ws.ListEnvironments() // Best effort try to detect if env manifest exists. for _, env := range envs { if e.envName == env { return fmt.Sprintf("You can upgrade the %q environment template by running %s.", e.envName, color.HighlightCode(fmt.Sprintf("copilot env deploy --name %s", e.envName))) } } msgs := []string{ "You can upgrade your environment template by running:", fmt.Sprintf("1. Create the directory to store your environment manifest %s.", color.HighlightCode(fmt.Sprintf("mkdir -p %s", filepath.Join("copilot", "environments", e.envName)))), fmt.Sprintf("2. Generate the manifest %s.", color.HighlightCode(fmt.Sprintf("copilot env show -n %s --manifest > %s", e.envName, filepath.Join("copilot", "environments", e.envName, "manifest.yml")))), fmt.Sprintf("3. Deploy the environment stack %s.", color.HighlightCode(fmt.Sprintf("copilot env deploy --name %s", e.envName))), } return strings.Join(msgs, "\n") } type errHasDiff struct{} func (e *errHasDiff) Error() string { return "" } // ExitCode returns 1 for a non-empty diff. func (e *errHasDiff) ExitCode() int { return 1 } func diff(differ templateDiffer, tmpl string, writer io.Writer) error { if out, err := differ.DeployDiff(tmpl); err != nil { return err } else if out != "" { if _, err := writer.Write([]byte(out)); err != nil { return err } return &errHasDiff{} } if _, err := writer.Write([]byte("No changes.\n")); err != nil { return err } return nil } // buildSvcDeployCmd builds the `svc deploy` subcommand. func buildSvcDeployCmd() *cobra.Command { vars := deployWkldVars{} cmd := &cobra.Command{ Use: "deploy", Short: "Deploys a service to an environment.", Long: `Deploys a service to an environment.`, Example: ` Deploys a service named "frontend" to a "test" environment. /code $ copilot svc deploy --name frontend --env test Deploys a service with additional resource tags. /code $ copilot svc deploy --resource-tags source/revision=bb133e7,deployment/initiator=manual`, RunE: runCmdE(func(cmd *cobra.Command, args []string) error { opts, err := newSvcDeployOpts(vars) if err != nil { return err } return run(opts) }), } cmd.Flags().StringVarP(&vars.appName, appFlag, appFlagShort, tryReadingAppName(), appFlagDescription) cmd.Flags().StringVarP(&vars.name, nameFlag, nameFlagShort, "", svcFlagDescription) cmd.Flags().StringVarP(&vars.envName, envFlag, envFlagShort, "", envFlagDescription) cmd.Flags().StringVar(&vars.imageTag, imageTagFlag, "", imageTagFlagDescription) cmd.Flags().StringToStringVar(&vars.resourceTags, resourceTagsFlag, nil, resourceTagsFlagDescription) cmd.Flags().BoolVar(&vars.forceNewUpdate, forceFlag, false, forceFlagDescription) cmd.Flags().BoolVar(&vars.disableRollback, noRollbackFlag, false, noRollbackFlagDescription) cmd.Flags().BoolVar(&vars.showDiff, diffFlag, false, diffFlagDescription) cmd.Flags().BoolVar(&vars.skipDiffPrompt, diffAutoApproveFlag, false, diffAutoApproveFlagDescription) cmd.Flags().BoolVar(&vars.allowWkldDowngrade, allowDowngradeFlag, false, allowDowngradeFlagDescription) cmd.Flags().BoolVar(&vars.detach, detachFlag, false, detachFlagDescription) return cmd }