internal/pkg/cli/svc_package.go (463 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"
"slices"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ssm"
clideploy "github.com/aws/copilot-cli/internal/pkg/cli/deploy"
"github.com/aws/copilot-cli/internal/pkg/deploy"
"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/version"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/aws/copilot-cli/internal/pkg/aws/identity"
"github.com/aws/copilot-cli/internal/pkg/aws/sessions"
"github.com/aws/copilot-cli/internal/pkg/config"
"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"
)
const (
svcPackageSvcNamePrompt = "Which service would you like to generate a CloudFormation template for?"
svcPackageEnvNamePrompt = "Which environment would you like to package this stack for?"
)
type packageSvcVars struct {
name string
envName string
appName string
tag string
outputDir string
uploadAssets bool
showDiff bool
allowWkldDowngrade bool
// To facilitate unit tests.
clientConfigured bool
}
type packageSvcOpts struct {
packageSvcVars
// Interfaces to interact with dependencies.
ws wsWlDirReader
fs afero.Fs
store store
templateWriter io.WriteCloser
paramsWriter io.WriteCloser
addonsWriter io.WriteCloser
diffWriter io.Writer
runner execRunner
svcVersionGetter versionGetter
sessProvider *sessions.Provider
sel wsSelector
unmarshal func([]byte) (manifest.DynamicWorkload, error)
newInterpolator func(app, env string) interpolator
newStackGenerator func(*packageSvcOpts) (workloadStackGenerator, error)
envFeaturesDescriber versionCompatibilityChecker
gitShortCommit string
// cached variables
targetApp *config.Application
targetEnv *config.Environment
envSess *session.Session
rawMft string
appliedDynamicMft manifest.DynamicWorkload
rootUserARN string
// Overridden in tests.
templateVersion string
}
func newPackageSvcOpts(vars packageSvcVars) (*packageSvcOpts, error) {
fs := afero.NewOsFs()
ws, err := workspace.Use(fs)
if err != nil {
return nil, err
}
sessProvider := sessions.ImmutableProvider(sessions.UserAgentExtras("svc package"))
defaultSess, err := sessProvider.Default()
if err != nil {
return nil, fmt.Errorf("default session: %v", err)
}
store := config.NewSSMStore(identity.New(defaultSess), ssm.New(defaultSess), aws.StringValue(defaultSess.Config.Region))
prompter := prompt.New()
opts := &packageSvcOpts{
packageSvcVars: vars,
store: store,
ws: ws,
fs: fs,
unmarshal: manifest.UnmarshalWorkload,
runner: exec.NewCmd(),
sel: selector.NewLocalWorkloadSelector(prompter, store, ws, selector.OnlyInitializedWorkloads),
templateWriter: os.Stdout,
paramsWriter: discardFile{},
addonsWriter: discardFile{},
diffWriter: os.Stdout,
templateVersion: version.LatestTemplateVersion(),
newInterpolator: newManifestInterpolator,
sessProvider: sessProvider,
newStackGenerator: newWorkloadStackGenerator,
}
return opts, nil
}
func newWorkloadStackGenerator(o *packageSvcOpts) (workloadStackGenerator, error) {
targetApp, err := o.getTargetApp()
if err != nil {
return nil, err
}
targetEnv, err := o.getTargetEnv()
if err != nil {
return nil, err
}
ovrdr, err := clideploy.NewOverrider(o.ws.WorkloadOverridesPath(o.name), o.appName, o.envName, o.fs, o.sessProvider)
if err != nil {
return nil, err
}
content := o.appliedDynamicMft.Manifest()
var deployer workloadStackGenerator
in := clideploy.WorkloadDeployerInput{
SessionProvider: o.sessProvider,
Name: o.name,
App: targetApp,
Env: targetEnv,
Image: clideploy.ContainerImageIdentifier{
CustomTag: o.tag,
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.ScheduledJob:
deployer, err = clideploy.NewJobDeployer(&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 template generator: %w", err)
}
return deployer, nil
}
// Validate returns an error for any invalid optional flags.
func (o *packageSvcOpts) Validate() error {
return nil
}
// Ask prompts for and validates any required flags.
func (o *packageSvcOpts) 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 prints the CloudFormation template of the application for the environment.
func (o *packageSvcOpts) 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
}
}
if o.outputDir != "" {
if err := o.setOutputFileWriters(); err != nil {
return err
}
}
targetEnv, err := o.getTargetEnv()
if err != nil {
return nil
}
gen, err := o.getStackGenerator(targetEnv)
if err != nil {
return err
}
stack, err := o.getWorkloadStack(gen)
if err != nil {
return err
}
if o.showDiff {
if err := diff(gen, stack.template, o.diffWriter); err != nil {
var errHasDiff *errHasDiff
if errors.As(err, &errHasDiff) {
return err
}
return &errDiffNotAvailable{
parentErr: err,
}
}
return nil
}
if err := o.writeAndClose(o.templateWriter, stack.template); err != nil {
return err
}
if err := o.writeAndClose(o.paramsWriter, stack.parameters); err != nil {
return err
}
addonsTemplate, err := gen.AddonsTemplate()
switch {
case err != nil:
return fmt.Errorf("retrieve addons template: %w", err)
case addonsTemplate == "":
return nil
}
// Addons template won't show up without setting --output-dir flag.
if o.outputDir != "" {
if err := o.setAddonsFileWriter(); err != nil {
return err
}
}
return o.writeAndClose(o.addonsWriter, addonsTemplate)
}
func (o *packageSvcOpts) validateOrAskSvcName() error {
if o.name != "" {
names, err := o.ws.ListServices()
if err != nil {
return fmt.Errorf("list services in the workspace: %w", err)
}
if !slices.Contains(names, o.name) {
return fmt.Errorf("service '%s' does not exist in the workspace", o.name)
}
return nil
}
name, err := o.sel.Service(svcPackageSvcNamePrompt, "")
if err != nil {
return fmt.Errorf("select service: %w", err)
}
o.name = name
return nil
}
func (o *packageSvcOpts) validateOrAskEnvName() error {
if o.envName != "" {
_, err := o.getTargetEnv()
return err
}
name, err := o.sel.Environment(svcPackageEnvNamePrompt, "", o.appName)
if err != nil {
return fmt.Errorf("select environment: %w", err)
}
o.envName = name
return nil
}
func (o *packageSvcOpts) configureClients() error {
o.gitShortCommit = imageTagFromGit(o.runner) // Best effort assign git tag.
// 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)
}
targetEnv, err := o.getTargetEnv()
if err != nil {
return err
}
envSess, err := o.sessProvider.FromRole(targetEnv.ManagerRoleARN, targetEnv.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 cfnStackConfig struct {
template string
parameters string
}
func (o *packageSvcOpts) getStackGenerator(env *config.Environment) (workloadStackGenerator, error) {
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 nil, err
}
o.appliedDynamicMft = mft
o.rawMft = interpolated
if err := validateWorkloadManifestCompatibilityWithEnv(o.ws, o.envFeaturesDescriber, o.appliedDynamicMft, o.envName); err != nil {
return nil, err
}
return o.newStackGenerator(o)
}
// getWorkloadStack returns the CloudFormation stack's template and its parameters for the service.
func (o *packageSvcOpts) getWorkloadStack(generator workloadStackGenerator) (*cfnStackConfig, error) {
targetApp, err := o.getTargetApp()
if err != nil {
return nil, err
}
var uploadOut clideploy.UploadArtifactsOutput
if o.uploadAssets {
out, err := generator.UploadArtifacts()
if err != nil {
return nil, fmt.Errorf("upload resources required for deployment for %s: %w", o.name, err)
}
uploadOut = *out
}
output, err := generator.GenerateCloudFormationTemplate(&clideploy.GenerateCloudFormationTemplateInput{
StackRuntimeConfiguration: clideploy.StackRuntimeConfiguration{
RootUserARN: o.rootUserARN,
Tags: targetApp.Tags,
EnvFileARNs: uploadOut.EnvFileARNs,
ImageDigests: uploadOut.ImageDigests,
AddonsURL: uploadOut.AddonsURL,
Version: o.templateVersion,
CustomResourceURLs: uploadOut.CustomResourceURLs,
StaticSiteAssetMappingURL: uploadOut.StaticSiteAssetMappingLocation,
},
})
if err != nil {
return nil, fmt.Errorf("generate workload %s template against environment %s: %w", o.name, o.envName, err)
}
return &cfnStackConfig{
template: output.Template,
parameters: output.Parameters}, nil
}
// setOutputFileWriters creates the output directory, and updates the template and param writers to file writers in the directory.
func (o *packageSvcOpts) setOutputFileWriters() error {
if err := o.fs.MkdirAll(o.outputDir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", o.outputDir, err)
}
templatePath := filepath.Join(o.outputDir,
fmt.Sprintf(deploy.WorkloadCfnTemplateNameFormat, o.name, o.envName))
templateFile, err := o.fs.Create(templatePath)
if err != nil {
return fmt.Errorf("create file %s: %w", templatePath, err)
}
o.templateWriter = templateFile
paramsPath := filepath.Join(o.outputDir,
fmt.Sprintf(deploy.WorkloadCfnTemplateConfigurationNameFormat, o.name, o.envName))
paramsFile, err := o.fs.Create(paramsPath)
if err != nil {
return fmt.Errorf("create file %s: %w", paramsPath, err)
}
o.paramsWriter = paramsFile
return nil
}
func (o *packageSvcOpts) setAddonsFileWriter() error {
addonsPath := filepath.Join(o.outputDir,
fmt.Sprintf(deploy.AddonsCfnTemplateNameFormat, o.name))
addonsFile, err := o.fs.Create(addonsPath)
if err != nil {
return fmt.Errorf("create file %s: %w", addonsPath, err)
}
o.addonsWriter = addonsFile
return nil
}
func (o *packageSvcOpts) 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
}
func (o *packageSvcOpts) getTargetEnv() (*config.Environment, error) {
if o.targetEnv != nil {
return o.targetEnv, nil
}
env, err := o.store.GetEnvironment(o.appName, o.envName)
if err != nil {
return nil, fmt.Errorf("get environment %s: %w", o.envName, err)
}
o.targetEnv = env
return o.targetEnv, nil
}
func (o *packageSvcOpts) writeAndClose(wc io.WriteCloser, dat string) error {
if _, err := wc.Write([]byte(dat)); err != nil {
return err
}
return wc.Close()
}
// RecommendActions is a no-op.
func (o *packageSvcOpts) RecommendActions() error {
return nil
}
type errDiffNotAvailable struct {
parentErr error
}
// Unwrap returns the parent error that is wrapped inside errDiffNotAvailable.
func (e *errDiffNotAvailable) Unwrap() error {
return e.parentErr
}
func (e *errDiffNotAvailable) Error() string {
return e.parentErr.Error()
}
// ExitCode returns 2 when a diff is unavailable due to a parent error.
func (e *errDiffNotAvailable) ExitCode() int {
return 2
}
// buildSvcPackageCmd builds the command for printing a service's CloudFormation template.
func buildSvcPackageCmd() *cobra.Command {
vars := packageSvcVars{}
cmd := &cobra.Command{
Use: "package",
Short: "Print the AWS CloudFormation template of a service.",
Long: `Print the CloudFormation template used to deploy a service to an environment.`,
Example: `
Print the CloudFormation template for the "frontend" service parametrized for the "test" environment.
/code $ copilot svc package -n frontend -e test
Write the CloudFormation stack and configuration to a "infrastructure/" sub-directory instead of stdout.
/startcodeblock
$ copilot svc package -n frontend -e test --output-dir ./infrastructure
$ ls ./infrastructure
frontend-test.stack.yml frontend-test.params.json
/endcodeblock`,
RunE: runCmdE(func(cmd *cobra.Command, args []string) error {
opts, err := newPackageSvcOpts(vars)
if err != nil {
return err
}
return run(opts)
}),
}
cmd.Flags().StringVarP(&vars.name, nameFlag, nameFlagShort, "", svcFlagDescription)
cmd.Flags().StringVarP(&vars.envName, envFlag, envFlagShort, "", envFlagDescription)
cmd.Flags().StringVarP(&vars.appName, appFlag, appFlagShort, tryReadingAppName(), appFlagDescription)
cmd.Flags().StringVar(&vars.tag, imageTagFlag, "", imageTagFlagDescription)
cmd.Flags().StringVar(&vars.outputDir, stackOutputDirFlag, "", stackOutputDirFlagDescription)
cmd.Flags().BoolVar(&vars.uploadAssets, uploadAssetsFlag, false, uploadAssetsFlagDescription)
cmd.Flags().BoolVar(&vars.showDiff, diffFlag, false, diffFlagDescription)
cmd.Flags().BoolVar(&vars.allowWkldDowngrade, allowDowngradeFlag, false, allowDowngradeFlagDescription)
cmd.MarkFlagsMutuallyExclusive(diffFlag, stackOutputDirFlag)
cmd.MarkFlagsMutuallyExclusive(diffFlag, uploadAssetsFlag)
return cmd
}