Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ linters:
- "!**/pkg/aws/identity/**"
- "!**/pkg/aws/organization/**"
- "!**/pkg/aws/security/**"
- "!**/pkg/aws/cloudformation/**"
- "!**/pkg/provisioner/backend/**"
# pkg/component/aws/cloudformation is the SDK-native aws/cloudformation
# component type; it deploys directly through the CloudFormation API and
Expand Down
158 changes: 125 additions & 33 deletions cmd/aws/cloudformation/cloudformation.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/cloudposse/atmos/cmd/aws/cloudformation/source"
errUtils "github.com/cloudposse/atmos/errors"
e "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/component"
Expand All @@ -25,6 +26,15 @@ const (
flagLabels = "labels"
// The valueTrue const is the string representation of a set boolean flag.
valueTrue = "true"

flagAutoApprove = "auto-approve"

// The subCommandApply/subCommandDelete consts are the Operation-dispatch
// identifiers shared by the top-level apply/deploy and delete verbs, and by
// verb-group entries that reuse the same literal (e.g. `changeset delete`'s
// use name).
subCommandApply = "apply"
subCommandDelete = "delete"
)

var cloudFormationParser *flags.StandardParser
Expand Down Expand Up @@ -61,33 +71,85 @@ func init() {
panic(err)
}

CloudFormationCmd.AddCommand(newOperationCommand("render", "Render the local template client-side (no API calls)"))
CloudFormationCmd.AddCommand(newOperationCommand("plan", "Preview changes an apply would make"))
CloudFormationCmd.AddCommand(newOperationCommand("diff", "Show changes an apply would make"))
CloudFormationCmd.AddCommand(newOperationCommand("apply", "Create or update the stack"))
CloudFormationCmd.AddCommand(newOperationCommand("deploy", "Apply with --auto-approve"))
CloudFormationCmd.AddCommand(newOperationCommand("delete", "Delete the stack"))
CloudFormationCmd.AddCommand(newOperationCommand("validate", "Validate the template server-side"))
outputCmd := newOperationCommand("output", "Show the deployed stack's Outputs")
CloudFormationCmd.AddCommand(newOperationCommand("render", "render", "Render the local template client-side (no API calls)"))
CloudFormationCmd.AddCommand(newOperationCommand("plan", "diff", "Preview changes an apply would make"))
CloudFormationCmd.AddCommand(newOperationCommand("diff", "diff", "Show changes an apply would make"))
CloudFormationCmd.AddCommand(newOperationCommand(subCommandApply, subCommandApply, "Create or update the stack"))
CloudFormationCmd.AddCommand(newOperationCommand("deploy", subCommandApply, "Apply with --auto-approve"))
CloudFormationCmd.AddCommand(newOperationCommand(subCommandDelete, subCommandDelete, "Delete the stack"))
CloudFormationCmd.AddCommand(newOperationCommand("validate", "validate", "Validate the template server-side"))
outputCmd := newOperationCommand("output", "output", "Show the deployed stack's Outputs")
outputCmd.Aliases = []string{"outputs"}
CloudFormationCmd.AddCommand(outputCmd)
CloudFormationCmd.AddCommand(newChangesetCmd())
CloudFormationCmd.AddCommand(newDriftCmd())
CloudFormationCmd.AddCommand(newGetCmd())
CloudFormationCmd.AddCommand(newOperationCommand("fmt", "fmt", "Format the local template in place (or check formatting with --check)"))
CloudFormationCmd.AddCommand(newListCmd())
CloudFormationCmd.AddCommand(source.GetSourceCommand())
}

// newChangesetCmd is the `atmos aws cloudformation changeset` verb group: manual
// control over changesets, complementing apply/deploy/diff's implicit flow.
func newChangesetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "changeset",
Short: "Manage aws/cloudformation changesets directly",
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Usage() },
}
cmd.AddCommand(newOperationCommand("create", "changeset-create", "Create a changeset and leave it for later review/execution"))
cmd.AddCommand(newOperationCommand("execute", "changeset-execute", "Execute a previously-created, named changeset"))
cmd.AddCommand(newOperationCommand("list", "changeset-list", "List a stack's changesets"))
cmd.AddCommand(newOperationCommand(subCommandDelete, "changeset-delete", "Delete a named changeset"))
return cmd
}

// newDriftCmd is the `atmos aws cloudformation drift` verb group.
func newDriftCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "drift",
Short: "Detect and describe drift against a deployed stack",
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Usage() },
}
cmd.AddCommand(newOperationCommand("detect", "drift-detect", "Run drift detection against the deployed stack"))
cmd.AddCommand(newOperationCommand("describe", "drift-describe", "Show the results of the most recent drift detection"))
return cmd
}

// newGetCmd is the `atmos aws cloudformation get` verb group.
func newGetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Fetch a deployed stack's template or policy",
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Usage() },
}
cmd.AddCommand(newOperationCommand("template", "get-template", "Fetch the deployed stack's template"))
cmd.AddCommand(newOperationCommand("policy", "get-policy", "Fetch the deployed stack's policy"))
return cmd
}

func newOperationCommand(name, short string) *cobra.Command {
// newOperationCommand builds an `atmos aws cloudformation <use> [component]`
// command (optionally nested under a verb group, e.g. `changeset create`).
// Use is the cobra command name (what the user types); subCommand is the
// internal Operation-dispatch identifier passed to provider.Execute (e.g.
// "changeset-create") and to operationFlagOptions/getOperationFlags — they
// differ for grouped verbs, where the same subCommand can be reached under a
// friendlier use (e.g. "plan" and "diff" both dispatch as "diff").
func newOperationCommand(use, subCommand, short string) *cobra.Command {
var parser *flags.StandardParser
cmd := &cobra.Command{
Use: name + " [component]",
Use: use + " [component]",
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
parsed, err := parser.Parse(context.Background(), args)
if err != nil {
return err
}
return runOperation(cmd, name, parsed.GetPositionalArgs())
return runOperation(cmd, subCommand, parsed.GetPositionalArgs())
},
}

options := operationFlagOptions(name)
options := operationFlagOptions(use, subCommand)
options = append(options, flags.WithConditionalPositionalArgPrompt(
"component",
"Choose an aws/cloudformation component",
Expand All @@ -114,8 +176,12 @@ func newOperationCommand(name, short string) *cobra.Command {

// operationFlagOptions returns the standard-parser options for an
// aws/cloudformation operation command: the shared selection/affected flags
// plus operation-specific flags.
func operationFlagOptions(name string) []flags.Option {
// plus operation-specific flags. Use is the cobra command name (only needed to
// distinguish apply's and deploy's differing --auto-approve default);
// subCommand is the unique Operation-dispatch identifier every other switch
// below keys on (use alone collides across verb groups, e.g. top-level
// `delete` and `changeset delete`).
func operationFlagOptions(use, subCommand string) []flags.Option {
options := []flags.Option{
flags.WithBoolFlag(flagAll, "", false, "Process all aws/cloudformation components in dependency order."),
flags.WithBoolFlag(flagAffected, "", false, "Process affected aws/cloudformation components in dependency order."),
Expand All @@ -130,30 +196,56 @@ func operationFlagOptions(name string) []flags.Option {
flags.WithStringSliceFlag(flagTags, "", nil, "Filter by tags (comma-separated, matches any): --tags=production,tier-1"),
flags.WithStringFlag(flagLabels, "", "", "Filter by labels (comma-separated key=value or key:value pairs, matches all): --labels=cost-center=platform,compliance=sox"),
}
options = append(options, operationSpecificFlagOptions(use, subCommand)...)
return options
}

if name == "apply" || name == "deploy" || name == "delete" {
options = append(options, flags.WithBoolFlag("auto-approve", "", name == "deploy", "Skip interactive confirmation."))
}
if name == "apply" || name == "deploy" {
options = append(options, flags.WithStringFlag("target", "", "", "Provision target to deliver to. Defaults to provision.default, otherwise the implicit direct-deploy target."))
}
if name == "delete" {
options = append(
options,
// operationSpecificFlagOptions returns the flags specific to one operation,
// split out of operationFlagOptions to keep its cyclomatic complexity low.
func operationSpecificFlagOptions(use, subCommand string) []flags.Option {
switch subCommand {
case subCommandApply:
options := []flags.Option{
flags.WithBoolFlag(flagAutoApprove, "", use == "deploy", "Skip interactive confirmation."),
flags.WithStringFlag("target", "", "", "Provision target to deliver to. Defaults to provision.default, otherwise the implicit direct-deploy target."),
}
return options
case subCommandDelete:
return []flags.Option{
flags.WithBoolFlag(flagAutoApprove, "", false, "Skip interactive confirmation."),
flags.WithStringSliceFlag("retain-resources", "", nil, "Logical IDs of resources to retain (only valid for a DELETE_FAILED stack)."),
flags.WithBoolFlag("disable-termination-protection", "", false, "Disable termination protection before deleting (never done silently)."),
)
}
if name == "output" {
options = append(
options,
}
case "output":
return []flags.Option{
flags.WithStringFlag("format", "", "table", "Output format: json|yaml|hcl|env|dotenv|bash|csv|tsv|table|github."),
flags.WithBoolFlag("flatten", "", false, "Flatten nested Outputs into compound keys."),
flags.WithBoolFlag("uppercase", "", false, "Uppercase Output keys."),
)
}
case "changeset-execute":
return []flags.Option{
flags.WithRequiredStringFlag("changeset-name", "", "Name of the changeset to execute."),
flags.WithBoolFlag(flagAutoApprove, "", false, "Skip interactive confirmation."),
}
case "changeset-delete":
return []flags.Option{
flags.WithRequiredStringFlag("changeset-name", "", "Name of the changeset to delete."),
}
case "drift-detect":
return []flags.Option{
flags.WithBoolFlag("fail-on-drift", "", false, "Exit non-zero if drift is detected (for CI)."),
}
case "get-template":
return []flags.Option{
flags.WithBoolFlag("original", "", false, "Fetch the user-submitted template instead of the processed one."),
}
case "fmt":
return []flags.Option{
flags.WithBoolFlag("check", "", false, "Report whether the template is formatted without writing changes (non-zero exit if not)."),
}
default:
return nil
}

return options
}

func validateOperationArgs(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -235,12 +327,12 @@ func runOperation(cmd *cobra.Command, subCommand string, args []string) error {

func getOperationFlags(cmd *cobra.Command) map[string]any {
result := make(map[string]any)
for _, name := range []string{flagAll, flagAffected, "include-dependents", "clone-target-ref", "auto-approve", "disable-termination-protection", "flatten", "uppercase"} {
for _, name := range []string{flagAll, flagAffected, "include-dependents", "clone-target-ref", flagAutoApprove, "disable-termination-protection", "flatten", "uppercase", "fail-on-drift", "original", "check"} {
if flag := cmd.Flag(name); flag != nil {
result[name] = flag.Value.String() == valueTrue
}
}
for _, name := range []string{"repo-path", "base", "ref", "sha", "ssh-key", "ssh-key-password", "target", "format"} {
for _, name := range []string{"repo-path", "base", "ref", "sha", "ssh-key", "ssh-key-password", "target", "format", "changeset-name"} {
if flag := cmd.Flag(name); flag != nil {
result[name] = flag.Value.String()
}
Expand Down
52 changes: 52 additions & 0 deletions cmd/aws/cloudformation/cloudformation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cloudformation

import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The fmt operation command must register the fmt-only --check flag (via
// operationSpecificFlagOptions's "fmt" case), defaulting to false.
func TestOperationSpecificFlagOptions_Fmt_RegistersCheckFlag(t *testing.T) {
fmtCmd := newOperationCommand("fmt", "fmt", "Format the local template in place")

checkFlag := fmtCmd.Flag("check")
require.NotNil(t, checkFlag, "expected fmt to register --check")
assert.Equal(t, "false", checkFlag.DefValue)

// --check is fmt-only: an unrelated operation must not pick it up.
applyCmd := newOperationCommand("apply", subCommandApply, "Create or update the stack")
assert.Nil(t, applyCmd.Flag("check"), "--check must be fmt-only")
}

// getOperationFlags must surface fmt's --check flag as a bool, both when set
// and when left at its default.
func TestGetOperationFlags_IncludesCheck(t *testing.T) {
fmtCmd := newOperationCommand("fmt", "fmt", "Format the local template in place")
require.NoError(t, fmtCmd.Flags().Set("check", "true"))

flags := getOperationFlags(fmtCmd)
assert.Equal(t, true, flags["check"])

fmtCmdDefault := newOperationCommand("fmt", "fmt", "Format the local template in place")
flags = getOperationFlags(fmtCmdDefault)
assert.Equal(t, false, flags["check"])
}

// CloudFormationCmd must mount the fmt subcommand, registered via
// subCommandOperations' "fmt" -> OperationFmt entry (exercised end-to-end
// through cmd registration rather than the internal map directly, since the
// map itself lives in pkg/component/aws/cloudformation and is covered there).
func TestCloudFormationCmd_RegistersFmtSubcommand(t *testing.T) {
var found *cobra.Command
for _, sub := range CloudFormationCmd.Commands() {
if sub.Name() == "fmt" {
found = sub
}
}
require.NotNil(t, found, "expected `atmos aws cloudformation fmt` to be registered")
assert.NotNil(t, found.Flag("check"))
}
Loading
Loading