internal/pkg/cli/env_package.go (324 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"
"github.com/aws/copilot-cli/internal/pkg/cli/deploy"
"github.com/aws/copilot-cli/internal/pkg/describe"
"github.com/aws/copilot-cli/internal/pkg/version"
"github.com/spf13/afero"
"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"
"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"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ssm"
"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/spf13/cobra"
)
const (
envCFNTemplateNameFmt = "%s.env.yml"
envCFNTemplateConfigurationNameFmt = "%s.env.params.json"
envAddonsCFNTemplateName = "env.addons.yml"
)
type packageEnvVars struct {
name string
appName string
outputDir string
uploadAssets bool
forceNewUpdate bool
showDiff bool
allowEnvDowngrade bool
}
type discardFile struct{}
func (df discardFile) Write(p []byte) (n int, err error) {
return io.Discard.Write(p)
}
func (df discardFile) Close() error {
return nil // noop
}
type packageEnvOpts struct {
packageEnvVars
// Dependencies.
cfgStore store
ws wsEnvironmentReader
sel wsEnvironmentSelector
caller identityService
fs afero.Fs
tplWriter io.WriteCloser
paramsWriter io.WriteCloser
addonsWriter io.WriteCloser
diffWriter io.Writer
newInterpolator func(appName, name string) interpolator
newEnvVersionGetter func(appName, name string) (versionGetter, error)
newEnvPackager func() (envPackager, error)
// Cached variables.
appCfg *config.Application
envCfg *config.Environment
// Overridden in tests.
templateVersion string
}
func newPackageEnvOpts(vars packageEnvVars) (*packageEnvOpts, error) {
sessProvider := sessions.ImmutableProvider(sessions.UserAgentExtras("env package"))
defaultSess, err := sessProvider.Default()
if err != nil {
return nil, fmt.Errorf("default session: %v", err)
}
fs := afero.NewOsFs()
ws, err := workspace.Use(fs)
if err != nil {
return nil, err
}
cfgStore := config.NewSSMStore(identity.New(defaultSess), ssm.New(defaultSess), aws.StringValue(defaultSess.Config.Region))
opts := &packageEnvOpts{
packageEnvVars: vars,
cfgStore: cfgStore,
ws: ws,
sel: selector.NewLocalEnvironmentSelector(prompt.New(), cfgStore, ws),
caller: identity.New(defaultSess),
fs: fs,
tplWriter: os.Stdout,
paramsWriter: discardFile{},
addonsWriter: discardFile{},
diffWriter: os.Stdout,
templateVersion: version.LatestTemplateVersion(),
newEnvVersionGetter: func(appName, name string) (versionGetter, error) {
return describe.NewEnvDescriber(describe.NewEnvDescriberConfig{
App: appName,
Env: name,
ConfigStore: cfgStore,
})
},
newInterpolator: func(appName, name string) interpolator {
return manifest.NewInterpolator(appName, name)
},
}
opts.newEnvPackager = func() (envPackager, error) {
appCfg, err := opts.getAppCfg()
if err != nil {
return nil, err
}
envCfg, err := opts.getEnvCfg()
if err != nil {
return nil, err
}
ovrdr, err := deploy.NewOverrider(ws.EnvOverridesPath(), envCfg.App, envCfg.Name, fs, sessProvider)
if err != nil {
return nil, err
}
return deploy.NewEnvDeployer(&deploy.NewEnvDeployerInput{
App: appCfg,
Env: envCfg,
SessionProvider: sessProvider,
ConfigStore: opts.cfgStore,
Workspace: ws,
Overrider: ovrdr,
})
}
return opts, nil
}
// Validate returns an error for any invalid optional flags.
func (o *packageEnvOpts) Validate() error {
return nil
}
// Ask prompts for and validates any required flags.
func (o *packageEnvOpts) Ask() error {
if o.appName == "" {
// This command is required to be executed under a workspace. We don't prompt for it.
return errNoAppInWorkspace
}
if _, err := o.getAppCfg(); err != nil {
return err
}
return o.validateOrAskName()
}
// Execute prints the CloudFormation configuration for the environment.
func (o *packageEnvOpts) Execute() error {
if !o.allowEnvDowngrade {
envVersionGetter, err := o.newEnvVersionGetter(o.appName, o.name)
if err != nil {
return err
}
if err := validateEnvVersion(envVersionGetter, o.name, o.templateVersion); err != nil {
return err
}
}
mft, interpolated, err := environmentManifest(o.name, o.ws, o.newInterpolator(o.appName, o.name))
if err != nil {
return err
}
principal, err := o.caller.Get()
if err != nil {
return fmt.Errorf("get caller principal identity: %v", err)
}
packager, err := o.newEnvPackager()
if err != nil {
return err
}
if err := packager.Validate(mft); err != nil {
return err
}
var uploadArtifactsOut deploy.UploadEnvArtifactsOutput
if o.uploadAssets {
out, err := packager.UploadArtifacts()
if err != nil {
return fmt.Errorf("upload assets for environment %q: %v", o.name, err)
}
uploadArtifactsOut = *out
}
res, err := packager.GenerateCloudFormationTemplate(&deploy.DeployEnvironmentInput{
RootUserARN: principal.RootUserARN,
AddonsURL: uploadArtifactsOut.AddonsURL,
CustomResourcesURLs: uploadArtifactsOut.CustomResourceURLs,
Manifest: mft,
RawManifest: interpolated,
PermissionsBoundary: o.appCfg.PermissionsBoundary,
ForceNewUpdate: o.forceNewUpdate,
Version: o.templateVersion,
})
if err != nil {
return fmt.Errorf("generate CloudFormation template from environment %q manifest: %v", o.name, err)
}
if o.showDiff {
if err := diff(packager, res.Template, o.diffWriter); err != nil {
var errHasDiff *errHasDiff
if errors.As(err, &errHasDiff) {
return err
}
return &errDiffNotAvailable{
parentErr: err,
}
}
return nil
}
addonsTemplate, err := packager.AddonsTemplate()
if err != nil {
return fmt.Errorf("retrieve environment addons template: %w", err)
}
if err := o.setWriters(); err != nil {
return err
}
if err := o.writeAndClose(o.tplWriter, res.Template); err != nil {
return err
}
if err := o.writeAndClose(o.paramsWriter, res.Parameters); err != nil {
return err
}
if addonsTemplate == "" {
return nil
}
if err := o.setAddonsWriter(); err != nil {
return err
}
return o.writeAndClose(o.addonsWriter, addonsTemplate)
}
func (o *packageEnvOpts) getAppCfg() (*config.Application, error) {
if o.appCfg != nil {
return o.appCfg, nil
}
cfg, err := o.cfgStore.GetApplication(o.appName)
if err != nil {
return nil, fmt.Errorf("get application %q configuration: %w", o.appName, err)
}
o.appCfg = cfg
return o.appCfg, nil
}
func (o *packageEnvOpts) getEnvCfg() (*config.Environment, error) {
if o.envCfg != nil {
return o.envCfg, nil
}
cfg, err := o.cfgStore.GetEnvironment(o.appName, o.name)
if err != nil {
return nil, fmt.Errorf("get environment %q in application %q: %w", o.name, o.appName, err)
}
o.envCfg = cfg
return o.envCfg, nil
}
func (o *packageEnvOpts) validateOrAskName() error {
if o.name != "" {
if _, err := o.getEnvCfg(); err != nil {
log.Errorf("It seems like environment %s is not added in application %s yet. Have you run %s?\n",
o.name, o.appName, color.HighlightCode("copilot env init"))
return err
}
return nil
}
name, err := o.sel.LocalEnvironment("Select an environment manifest from your workspace", "")
if err != nil {
return fmt.Errorf("select environment: %w", err)
}
o.name = name
return nil
}
func (o *packageEnvOpts) setWriters() error {
if o.outputDir == "" {
return nil
}
if err := o.fs.MkdirAll(o.outputDir, 0755); err != nil {
return fmt.Errorf("create directory %q: %w", o.outputDir, err)
}
path := filepath.Join(o.outputDir, fmt.Sprintf(envCFNTemplateNameFmt, o.name))
tplFile, err := o.fs.Create(path)
if err != nil {
return fmt.Errorf("create file at %q: %w", path, err)
}
path = filepath.Join(o.outputDir, fmt.Sprintf(envCFNTemplateConfigurationNameFmt, o.name))
paramsFile, err := o.fs.Create(path)
if err != nil {
return fmt.Errorf("create file at %q: %w", path, err)
}
o.tplWriter = tplFile
o.paramsWriter = paramsFile
return nil
}
func (o *packageEnvOpts) setAddonsWriter() error {
if o.outputDir == "" {
return nil
}
addonsPath := filepath.Join(o.outputDir, envAddonsCFNTemplateName)
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 *packageEnvOpts) writeAndClose(wc io.WriteCloser, dat string) error {
if _, err := wc.Write([]byte(dat)); err != nil {
return err
}
return wc.Close()
}
// buildEnvPkgCmd builds the command for printing an environment CloudFormation stack configuration.
func buildEnvPkgCmd() *cobra.Command {
vars := packageEnvVars{}
cmd := &cobra.Command{
Use: "package",
Short: "Print the AWS CloudFormation template of an environment.",
Long: `Print the CloudFormation stack template and configuration used to deploy an environment.`,
Example: `
Print the CloudFormation template for the "prod" environment.
/code $ copilot env package -n prod --upload-assets
Write the CloudFormation template and configuration to a "infrastructure/" sub-directory instead of stdout.
/startcodeblock
$ copilot env package -n test --output-dir ./infrastructure --upload-assets
$ ls ./infrastructure
test.env.yml test.env.params.json
/endcodeblock`,
RunE: runCmdE(func(cmd *cobra.Command, args []string) error {
opts, err := newPackageEnvOpts(vars)
if err != nil {
return err
}
return run(opts)
}),
}
cmd.Flags().StringVarP(&vars.name, nameFlag, nameFlagShort, "", envFlagDescription)
cmd.Flags().StringVarP(&vars.appName, appFlag, appFlagShort, tryReadingAppName(), appFlagDescription)
cmd.Flags().StringVar(&vars.outputDir, stackOutputDirFlag, "", stackOutputDirFlagDescription)
cmd.Flags().BoolVar(&vars.uploadAssets, uploadAssetsFlag, false, uploadAssetsFlagDescription)
cmd.Flags().BoolVar(&vars.forceNewUpdate, forceFlag, false, forceEnvDeployFlagDescription)
cmd.Flags().BoolVar(&vars.showDiff, diffFlag, false, diffFlagDescription)
cmd.Flags().BoolVar(&vars.allowEnvDowngrade, allowDowngradeFlag, false, allowDowngradeFlagDescription)
cmd.MarkFlagsMutuallyExclusive(diffFlag, stackOutputDirFlag)
cmd.MarkFlagsMutuallyExclusive(diffFlag, uploadAssetsFlag)
return cmd
}