// 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" "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 // 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 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), 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 } raw, err := o.ws.ReadWorkloadManifest(o.name) if err != nil { return nil, fmt.Errorf("read manifest file for %s: %w", o.name, 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: raw, 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, err := workloadManifest(&workloadManifestInput{ name: o.name, appName: o.appName, envName: o.envName, interpolator: o.newInterpolator(o.appName, o.envName), ws: o.ws, unmarshal: o.unmarshal, sess: o.envSess, }) if err != nil { return err } if o.forceNewUpdate && o.svcType == manifestinfo.StaticSiteType { return fmt.Errorf("--%s is not supported for service type %q", forceFlag, manifestinfo.StaticSiteType) } o.appliedDynamicMft = mft 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 } } var errStackDeletedOnInterrupt *deploycfn.ErrStackDeletedOnInterrupt var errStackUpdateCanceledOnInterrupt *deploycfn.ErrStackUpdateCanceledOnInterrupt 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, }, }) if err != nil { 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")) } return fmt.Errorf("deploy service %s to environment %s: %w", o.name, o.envName, err) } 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 o.noDeploy { 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, 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, 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." } return []string{ fmt.Sprintf("You can access your service 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 {} = 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) return cmd }