diff --git a/cmd/terraform.go b/cmd/terraform.go index c016b40faf..112daffde9 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -1,16 +1,17 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" - "github.com/cloudposse/atmos/pkg/hooks" + cfg "github.com/cloudposse/atmos/pkg/config" + h "github.com/cloudposse/atmos/pkg/hooks" u "github.com/cloudposse/atmos/pkg/utils" -) -type contextKey string - -const atmosInfoKey contextKey = "atmos_info" + l "github.com/charmbracelet/log" +) // terraformCmd represents the base command for all terraform sub-commands var terraformCmd = &cobra.Command{ @@ -19,10 +20,6 @@ var terraformCmd = &cobra.Command{ Short: "Execute Terraform commands (e.g., plan, apply, destroy) using Atmos stack configurations", Long: `This command allows you to execute Terraform commands, such as plan, apply, and destroy, using Atmos stack configurations for consistent infrastructure management.`, FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: true}, - PostRunE: func(cmd *cobra.Command, args []string) error { - info := getConfigAndStacksInfo("terraform", cmd, args) - return hooks.RunE(cmd, args, &info) - }, } // Contains checks if a slice of strings contains an exact match for the target string. @@ -47,6 +44,28 @@ func terraformRun(cmd *cobra.Command, actualCmd *cobra.Command, args []string) { } } +func runHooks(event h.HookEvent, cmd *cobra.Command, args []string) error { + info := getConfigAndStacksInfo("terraform", cmd, append([]string{cmd.Name()}, args...)) + + // Initialize the CLI config + atmosConfig, err := cfg.InitCliConfig(info, true) + if err != nil { + return fmt.Errorf("error initializing CLI config: %w", err) + } + + hooks, err := h.GetHooks(&atmosConfig, &info) + if err != nil { + return fmt.Errorf("error getting hooks: %w", err) + } + + if hooks.HasHooks() { + l.Info("running hooks", "event", event) + return hooks.RunAll(event, &atmosConfig, &info, cmd, args) + } + + return nil +} + func init() { // https://github.com/spf13/cobra/issues/739 terraformCmd.DisableFlagParsing = true diff --git a/cmd/terraform_commands.go b/cmd/terraform_commands.go index 1bb077145f..aef1c06fb4 100644 --- a/cmd/terraform_commands.go +++ b/cmd/terraform_commands.go @@ -3,6 +3,7 @@ package cmd import ( "os" + h "github.com/cloudposse/atmos/pkg/hooks" "github.com/spf13/cobra" ) @@ -27,12 +28,15 @@ func getTerraformCommands() []*cobra.Command { Annotations: map[string]string{ "nativeCommand": "true", }, + PostRunE: func(cmd *cobra.Command, args []string) error { + return runHooks(h.AfterTerraformApply, cmd, args) + }, }, { Use: "workspace", Short: "Manage Terraform workspaces", Long: `The 'atmos terraform workspace' command initializes Terraform for the current configuration, selects the specified workspace, and creates it if it does not already exist. - + It runs the following sequence of Terraform commands: 1. 'terraform init -reconfigure' to initialize the working directory. 2. 'terraform workspace select' to switch to the specified workspace. @@ -57,6 +61,9 @@ Common use cases: Use: "deploy", Short: "Deploy the specified infrastructure using Terraform", Long: `Deploys infrastructure by running the Terraform apply command with automatic approval. This ensures that the changes defined in your Terraform configuration are applied without requiring manual confirmation, streamlining the deployment process.`, + PostRunE: func(cmd *cobra.Command, args []string) error { + return runHooks(h.AfterTerraformApply, cmd, args) + }, }, { Use: "shell", @@ -151,12 +158,12 @@ Common use cases: Use: "import", Short: "Import existing infrastructure into Terraform state.", Long: `The 'atmos terraform import' command imports existing infrastructure resources into Terraform's state. - + Before executing the command, it searches for the 'region' variable in the specified component and stack configuration. If the 'region' variable is found, it sets the 'AWS_REGION' environment variable with the corresponding value before executing the import command. - + The import command runs: 'terraform import [ADDRESS] [ID]' - + Arguments: - ADDRESS: The Terraform address of the resource to import. - ID: The ID of the resource to import.`, diff --git a/go.mod b/go.mod index ed065fbf9e..adc167291d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( dario.cat/mergo v1.0.1 github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/chroma v0.10.0 + github.com/alicebob/miniredis/v2 v2.34.0 github.com/arsham/figurine v1.3.0 github.com/aws/aws-sdk-go-v2 v1.36.0 github.com/aws/aws-sdk-go-v2/config v1.29.4 @@ -81,6 +82,7 @@ require ( github.com/agnivade/levenshtein v1.2.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/participle/v2 v2.1.1 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect diff --git a/go.sum b/go.sum index a9f3483a13..c1bfc8eaa8 100644 --- a/go.sum +++ b/go.sum @@ -704,6 +704,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0= +github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000000..e017056c4e --- /dev/null +++ b/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "os" + "path" + "testing" + + "github.com/alicebob/miniredis/v2" +) + +func TestMainHooksAndStoreIntegration(t *testing.T) { + // Run the miniredis server so we can store values across calls to main() + s := miniredis.RunT(t) + defer s.Close() + + redisUrl := fmt.Sprintf("redis://%s", s.Addr()) + os.Setenv("ATMOS_REDIS_URL", redisUrl) + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + defer os.RemoveAll(path.Join(origDir, "testdata", "fixtures", "hooks-test", ".terraform")) + defer os.Chdir(origDir) + + if err := os.Chdir("testdata/fixtures/hooks-test"); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Capture the original arguments + origArgs := os.Args + defer func() { os.Args = origArgs }() + + // Set the arguments for the first call to main() to deeploy the `random1` component, which uses a `hook` to set a + // value in Redis + os.Args = []string{"atmos", "terraform", "deploy", "random1", "-s", "test"} + main() + + // Set the arguments for the second call to main() to deeploy the `random2` component, which uses a `store` to read a + // value that was set in the first apply. + os.Args = []string{"atmos", "terraform", "deploy", "random2", "-s", "test"} + main() +} diff --git a/pkg/hooks/cmd.go b/pkg/hooks/cmd.go deleted file mode 100644 index c14ffabb9d..0000000000 --- a/pkg/hooks/cmd.go +++ /dev/null @@ -1,91 +0,0 @@ -package hooks - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/log" - e "github.com/cloudposse/atmos/internal/exec" - cfg "github.com/cloudposse/atmos/pkg/config" - "github.com/cloudposse/atmos/pkg/schema" - u "github.com/cloudposse/atmos/pkg/utils" - "github.com/spf13/cobra" -) - -func isTerraformApplyCommand(cmd *string) bool { - return strings.ToLower(*cmd) == "apply" || strings.ToLower(*cmd) == "deploy" -} - -func isStoreCommand(cmd *string) bool { - return strings.ToLower(*cmd) == "store" -} - -func getOutputValue(atmosConfig schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, value string) (string, any) { - outputKey := strings.TrimPrefix(value, ".") - var outputValue any - - if strings.Index(value, ".") == 0 { - outputValue = e.GetTerraformOutput(&atmosConfig, info.Stack, info.ComponentFromArg, outputKey, true) - } else { - outputValue = value - } - return outputKey, outputValue -} - -func storeOutput(atmosConfig schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, hook Hook, key string, outputKey string, outputValue any) error { - store := atmosConfig.Stores[hook.Name] - if store == nil { - return fmt.Errorf("store %q not found in configuration", hook.Name) - } - log.Info("storing terraform output", "outputKey", outputKey, "store", hook.Name, "key", key, "value", outputValue) - - return store.Set(info.Stack, info.ComponentFromArg, key, outputValue) -} - -func processStoreCommand(atmosConfig schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, hook Hook) error { - if len(hook.Outputs) == 0 { - log.Info("skipping hook. no outputs configured.", "hook", hook.Name, "outputs", hook.Outputs) - return nil - } - - log.Info("executing 'after-terraform-apply' hook", "hook", hook.Name, "command", hook.Command) - for key, value := range hook.Outputs { - outputKey, outputValue := getOutputValue(atmosConfig, info, value) - - err := storeOutput(atmosConfig, info, hook, key, outputKey, outputValue) - if err != nil { - return err - } - } - return nil -} - -func RunE(cmd *cobra.Command, args []string, info *schema.ConfigAndStacksInfo) error { - atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) - if err != nil { - u.LogErrorAndExit(err) - } - - sections, err := e.ExecuteDescribeComponent(info.ComponentFromArg, info.Stack, true, true, nil) - if err != nil { - u.LogErrorAndExit(err) - } - - if isTerraformApplyCommand(&info.SubCommand) { - hooks := Hooks{} - hooks, err = hooks.ConvertToHooks(sections["hooks"].(map[string]any)) - if err != nil { - u.LogErrorAndExit(fmt.Errorf("invalid hooks section %v", sections["hooks"])) - } - - for _, hook := range hooks { - if isStoreCommand(&hook.Command) { - err = processStoreCommand(atmosConfig, info, hook) - if err != nil { - return err - } - } - } - } - return nil -} diff --git a/pkg/hooks/command.go b/pkg/hooks/command.go new file mode 100644 index 0000000000..e70d8a9ae4 --- /dev/null +++ b/pkg/hooks/command.go @@ -0,0 +1,9 @@ +package hooks + +import "github.com/spf13/cobra" + +// Command is the interface for all commands that can be run by hooks +type Command interface { + GetName() string + RunE(hook *Hook, event HookEvent, cmd *cobra.Command, args []string) error +} diff --git a/pkg/hooks/event.go b/pkg/hooks/event.go new file mode 100644 index 0000000000..561c77368d --- /dev/null +++ b/pkg/hooks/event.go @@ -0,0 +1,10 @@ +package hooks + +type HookEvent string + +const ( + AfterTerraformApply HookEvent = "after.terraform.apply" + BeforeTerraformApply HookEvent = "before.terraform.apply" + AfterTerraformPlan HookEvent = "after.terraform.plan" + BeforeTerraformPlan HookEvent = "before.terraform.plan" +) diff --git a/pkg/hooks/hook.go b/pkg/hooks/hook.go new file mode 100644 index 0000000000..2fdb0f125d --- /dev/null +++ b/pkg/hooks/hook.go @@ -0,0 +1,14 @@ +package hooks + +// Hook is the structure for a hook and is using in the stack config to define +// a command that should be run when a specific event occurs. +type Hook struct { + Events []string `yaml:"events"` + Command string `yaml:"command"` + + // Dynamic command-specific properties + + // store command + Name string `yaml:"name,omitempty"` // for store command + Outputs map[string]string `yaml:"outputs,omitempty"` // for store command +} diff --git a/pkg/hooks/hooks.go b/pkg/hooks/hooks.go index 5e921ced85..4acfe542f8 100644 --- a/pkg/hooks/hooks.go +++ b/pkg/hooks/hooks.go @@ -3,49 +3,72 @@ package hooks import ( "fmt" + log "github.com/charmbracelet/log" + e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" + "github.com/spf13/cobra" "gopkg.in/yaml.v2" ) -type HookType string - -const ( - AfterTerraformApply HookType = "after.terraform.apply" - BeforeTerraformApply HookType = "before.terraform.apply" - AfterTerraformPlan HookType = "after.terraform.plan" - BeforeTerraformPlan HookType = "before.terraform.plan" -) - -type Command string - -const ( - Store Command = "store" -) - -// Hook defines the structure of a hook -type Hook struct { - Events []string `yaml:"events"` - Command string `yaml:"command"` - - // Dynamic command-specific properties +type Hooks struct { + config *schema.AtmosConfiguration + info *schema.ConfigAndStacksInfo + items map[string]Hook +} - // store command - Name string `yaml:"name,omitempty"` // for store command - Outputs map[string]string `yaml:"outputs,omitempty"` // for store command +func (h Hooks) HasHooks() bool { + return len(h.items) > 0 } -type Hooks map[string]Hook +func GetHooks(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (*Hooks, error) { + sections, err := e.ExecuteDescribeComponent(info.ComponentFromArg, info.Stack, true, true, []string{}) + if err != nil { + return &Hooks{}, fmt.Errorf("failed to execute describe component: %w", err) + } -func (h Hooks) ConvertToHooks(input map[string]any) (Hooks, error) { - yamlData, err := yaml.Marshal(input) + hooksSection := sections["hooks"].(map[string]any) + + yamlData, err := yaml.Marshal(hooksSection) if err != nil { - return nil, fmt.Errorf("failed to marshal input: %w", err) + return &Hooks{}, fmt.Errorf("failed to marshal hooksSection: %w", err) } - var hooks Hooks - err = yaml.Unmarshal(yamlData, &hooks) + var items map[string]Hook + err = yaml.Unmarshal(yamlData, &items) if err != nil { return nil, fmt.Errorf("failed to unmarshal to Hooks: %w", err) } - return hooks, nil + hooks := Hooks{ + config: atmosConfig, + info: info, + items: items, + } + + return &hooks, nil +} + +func (h Hooks) RunAll(event HookEvent, atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, cmd *cobra.Command, args []string) error { + log.Debug("running hooks", "count", len(h.items)) + + for _, hook := range h.items { + switch hook.Command { + case "store": + storeCmd := &StoreCommand{ + Name: "store", + atmosConfig: atmosConfig, + info: info, + } + err := storeCmd.RunE(&hook, event, cmd, args) + if err != nil { + u.LogErrorAndExit(err) + } + } + } + return nil +} + +func (h Hooks) ConvertToHooks(input map[string]any) (Hooks, error) { + return Hooks{}, nil } diff --git a/pkg/hooks/store.go b/pkg/hooks/store.go deleted file mode 100644 index 6de678e592..0000000000 --- a/pkg/hooks/store.go +++ /dev/null @@ -1 +0,0 @@ -package hooks diff --git a/pkg/hooks/store_cmd.go b/pkg/hooks/store_cmd.go new file mode 100644 index 0000000000..f0fc04d9d3 --- /dev/null +++ b/pkg/hooks/store_cmd.go @@ -0,0 +1,82 @@ +package hooks + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/log" + e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/spf13/cobra" +) + +// assert that Command implements Command interface +var _ Command = &StoreCommand{} + +type StoreCommand struct { + Name string + atmosConfig *schema.AtmosConfiguration + info *schema.ConfigAndStacksInfo +} + +func NewStoreCommand(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (*StoreCommand, error) { + return &StoreCommand{ + Name: "store", + atmosConfig: atmosConfig, + info: info, + }, nil +} + +func (c *StoreCommand) GetName() string { + return c.Name +} + +func (c *StoreCommand) processStoreCommand(hook *Hook) error { + if len(hook.Outputs) == 0 { + log.Info("skipping hook. no outputs configured.", "hook", hook.Name, "outputs", hook.Outputs) + return nil + } + + log.Debug("executing 'after-terraform-apply' hook", "hook", hook.Name, "command", hook.Command) + for key, value := range hook.Outputs { + outputKey, outputValue := c.getOutputValue(value) + + err := c.storeOutput(hook, key, outputKey, outputValue) + if err != nil { + return err + } + } + return nil +} + +// getOutputValue gets an output from terraform +func (c *StoreCommand) getOutputValue(value string) (string, any) { + outputKey := strings.TrimPrefix(value, ".") + var outputValue any + + if strings.Index(value, ".") == 0 { + outputValue = e.GetTerraformOutput(c.atmosConfig, c.info.Stack, c.info.ComponentFromArg, outputKey, true) + } else { + outputValue = value + } + return outputKey, outputValue +} + +// storeOutput puts the value of the output in the store +func (c *StoreCommand) storeOutput(hook *Hook, key string, outputKey string, outputValue any) error { + log.Debug("checking if the store exists", "store", hook.Name) + store := c.atmosConfig.Stores[hook.Name] + + if store == nil { + return fmt.Errorf("store %q not found in configuration", hook.Name) + } + + log.Debug("storing terraform output", "outputKey", outputKey, "store", hook.Name, "key", key, "value", outputValue) + + return store.Set(c.info.Stack, c.info.ComponentFromArg, key, outputValue) +} + +// RunE is the entrypoint for the store command +func (c *StoreCommand) RunE(hook *Hook, event HookEvent, cmd *cobra.Command, args []string) error { + return c.processStoreCommand(hook) +} diff --git a/testdata/fixtures/hooks-test/atmos.yaml b/testdata/fixtures/hooks-test/atmos.yaml new file mode 100644 index 0000000000..722a30fd17 --- /dev/null +++ b/testdata/fixtures/hooks-test/atmos.yaml @@ -0,0 +1,25 @@ +base_path: "./" + +stores: + testredis: + type: redis + +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + +stacks: + base_path: "." + included_paths: + - "stacks/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +logs: + file: "/dev/stderr" + level: Info diff --git a/testdata/fixtures/hooks-test/components/terraform/random/main.tf b/testdata/fixtures/hooks-test/components/terraform/random/main.tf new file mode 100644 index 0000000000..2fa0f8f9c8 --- /dev/null +++ b/testdata/fixtures/hooks-test/components/terraform/random/main.tf @@ -0,0 +1,20 @@ +variable "stage" { + description = "Stage where it will be deployed" + type = string +} + +variable "random" { + type = string + default = "random" +} + +resource "null_resource" "this" { + # Changes to any instance of the cluster requires re-provisioning + triggers = { + random = var.random + } +} + +output "random" { + value = null_resource.this.triggers.random +} diff --git a/testdata/fixtures/hooks-test/stacks/stack.yaml b/testdata/fixtures/hooks-test/stacks/stack.yaml new file mode 100644 index 0000000000..f589093e19 --- /dev/null +++ b/testdata/fixtures/hooks-test/stacks/stack.yaml @@ -0,0 +1,23 @@ +components: + terraform: + random1: + metadata: + component: random + hooks: + store-outputs: + events: + - after-terraform-apply + command: store + name: testredis + outputs: + random_id: .random + vars: + stage: test + random: "random1" + + random2: + metadata: + component: random + vars: + stage: test + random: !store testredis random1 random_id