diff --git a/.env b/.env index 55e1665..e4e4977 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=v0.0.1 +VERSION=v0.0.2 diff --git a/README.md b/README.md index 622a66f..ddcafa7 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ ## Quick start ### CLI - tfvars-atlantis-config generate --automerge --autoplan --parallel --output=atlantis.yaml + tfvars-atlantis-config generate --use-workspaces --automerge --autoplan --parallel --output=atlantis.yaml ### Atlantis Server Side Config ```yaml repos: - id: /.*/ pre_workflow_hooks: - - run: tfvars-atlantis-config generate --automerge --autoplan --parallel --output=atlantis.yaml + - run: tfvars-atlantis-config generate --use-workspaces --automerge --autoplan --parallel --output=atlantis.yaml ``` @@ -51,7 +51,7 @@ parallel_apply: true projects: - name: my-terraform-dev dir: my-terraform - workflow: my-terraform-dev + workspace: dev autoplan: when_modified: - '*.tf' @@ -59,33 +59,12 @@ projects: enabled: true - name: my-terraform-prod dir: my-terraform - workflow: my-terraform-prod + workspace: prod autoplan: when_modified: - '*.tf' - prod.tfvars enabled: true -workflows: - my-terraform-dev: - plan: - steps: - - init - - plan: - extra_args: - - -var-file=dev.tfvars - apply: - steps: - - apply - my-terraform-prod: - plan: - steps: - - init - - plan: - extra_args: - - -var-file=prod.tfvars - apply: - steps: - - apply ``` ## Why you should use it? @@ -107,24 +86,49 @@ runtime. | `--autoplan` | Enable auto plan. | false | | `--default-terraform-version` | Default terraform version to run for Atlantis. Default is determined by the Terraform version constraints. | "" | | `--debug` | Enable debug logging. | false | -| `--multienv` | Enable injection of environment specific environment variables to each workflow. | false | | `--output` | Path of the file where configuration will be generated, usually `atlantis.yaml`. Default is to write to `stdout` | `stdout` | | `--parallel` | Enables plans and applys to happen in parallel. | false | | `--root` | Path to the root directory of the git repo you want to build config for. Default is current dir. | `.` | | `--use-workspaces` | Whether to use Terraform workspaces for projects. | false | -## Multienv -When `--multienv` is enabled, prefixed environment variables will be +## Workflows +This utility does not generate workflows. You can use use the `$WORKSPACE` +environment variable as part of a generic plan step to use the generated +configuration. + +See the [multienv](#multienv--provider-configuration) for a working example. + +## Multienv / Provider configuration +You can run the `multienv` command in a workflow. Prefixed environment variables will be stripped of their prefix and injected into each workflow for the duration the workflow is run during plan/apply stages. -### Example +This is useful when you want to configure providers via environment variables +on a per-workspace basis. +``` + workflows: + default: + plan: + steps: + - init + - multienv: tfvars-atlantis-config multienv + - plan: + extra_args: ["-var-file", "$WORKSPACE.tfvars"] + apply: + steps: + - multienv: tfvars-atlantis-config multienv + - apply +``` + +### Example +Workspace `dev`: _dev.tfvars_: - `DEV_FOO_VAR="BAR"` -> `FOO_VAR="BAR"` - `DEV_AWS_ACCESS_KEY="..."` -> `AWS_ACCESS_KEY="..."` +Workspace `stg`: _stg.tfvars_: - `STG_FOO_VAR="BAR"` -> `FOO_VAR="BAR"` diff --git a/cmd/generate.go b/cmd/generate.go index ff2d812..efcad44 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -38,7 +38,6 @@ func NewFlags() (*Flags, error) { AutoPlan: false, DefaultTerraformVersion: "", Root: pwd, - MultiEnv: false, Output: "", Parallel: false, UseWorkspaces: false, @@ -52,7 +51,6 @@ func (flags *Flags) AddFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&flags.Parallel, "parallel", flags.Parallel, "Enables plans and applys to happen in parallel. Default is disabled") cmd.Flags().StringVar(&flags.Output, "output", flags.Output, "Path of the file where configuration will be generated. Default is stdout") cmd.Flags().StringVar(&flags.Root, "root", flags.Root, "Path to the root directory of the git repo you want to build config for. Default is current dir") - cmd.Flags().BoolVar(&flags.MultiEnv, "multienv", flags.MultiEnv, "Enable injection of environment specific environment variables to each workflow. Default is disabled") cmd.Flags().StringVar(&flags.DefaultTerraformVersion, "terraform-version", flags.DefaultTerraformVersion, "Default terraform version to run for Atlantis. Default is determined by the Terraform version constraints.") cmd.Flags().BoolVar(&flags.UseWorkspaces, "use-workspaces", flags.UseWorkspaces, "Use workspaces for projects. Default is disabled") @@ -67,7 +65,6 @@ func (flags *Flags) toOptions() repocfg.Options { Automerge: flags.AutoMerge, Autoplan: flags.AutoPlan, DefaultTerraformVersion: flags.DefaultTerraformVersion, - MultiEnv: flags.MultiEnv, Parallel: flags.Parallel, UseWorkspaces: flags.UseWorkspaces, } diff --git a/cmd/multienv.go b/cmd/multienv.go new file mode 100644 index 0000000..a050ae5 --- /dev/null +++ b/cmd/multienv.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/3bbbeau/tfvars-atlantis-config/logger" + "github.com/spf13/cobra" +) + +const ( + // ATLANTIS_WORKSPACE is the environment variable that contains the name of + // the workspace that Atlantis is currently running for. + ATLANTIS_WORKSPACE = "WORKSPACE" +) + +// NewMultiEnvCmd creates a new `multienv` command +func NewMultiEnvCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "multienv", + Short: "Returns a string representing the multienv for Atlantis", + Long: `Returns a string representing the multienv for Atlantis, e.g.: + EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3 + + Reference: https://www.runatlantis.io/docs/custom-workflows.html#multiple-environment-variables-multienv-command + + This is useful when you want to configure providers via environment variables + on a per-workspace/environment basis.`, + RunE: func(cmd *cobra.Command, args []string) error { + workspace := os.Getenv(ATLANTIS_WORKSPACE) + if len(workspace) == 0 { + return fmt.Errorf("environment variable %s is not set. no workspace for multienv", ATLANTIS_WORKSPACE) + } + + multienv, err := multienv(os.Getenv(ATLANTIS_WORKSPACE)) + if err != nil { + if errors.Is(err, ErrNoEnvVars) { + logger.FromContext(cmd.Context()).Debug("no matching prefixed environment variables found") + // Return nil to indicate success, but no multienv string + return nil + } + return err + } + + // Atlanis expects the multienv string to be written to stdout + fmt.Print(multienv) + return nil + }, + } + return cmd +} + +var ErrNoEnvVars error = fmt.Errorf("no matching prefixed environment variables found") + +// Generates the Atlantis multienv string for multi-environment +// Terraform projects, e.g: +// +// EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3 +// +// Given a prefix for the workspace name, strips the +// environment name from existing environment vars while keeping their value. +// +// This is useful when you want to configure providers via environment variables +// on a per-environment basis. +// +// Example: +// +// DEV_AWS_ACCESS_KEY_ID="foo" +// DEV_AWS_SECRET_ACCESS_KEY="bar" +// -> +// AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID +// AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY +func multienv(prefix string) (string, error) { + prefix = strings.ToUpper(prefix) + if !strings.HasSuffix(prefix, "_") { + prefix += "_" + } + + strippedEnviron := []string{} + for _, v := range os.Environ() { + if strings.HasPrefix(v, prefix) { + // Limits the split to only two parts, separating the key from the first + // occurrence of '=', otherwise if the value contains '=' character(s) the + // string would be split into more than two parts. + split := strings.SplitN(v, "=", 2) + + // Strips the prefix from the environment variable name, e.g. "DEV_" from + // "DEV_AWS_ACCESS_KEY_ID" and let it equal to the original environment variable + strippedEnviron = append(strippedEnviron, fmt.Sprintf("%s=%s", strings.TrimPrefix(split[0], prefix), split[1])) + } + } + if len(strippedEnviron) == 0 { + return "", ErrNoEnvVars + } + return strings.Join(strippedEnviron, ","), nil +} diff --git a/cmd/root.go b/cmd/root.go index b2c86f6..20b4a2c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,6 +35,7 @@ func New() (*cobra.Command, error) { return nil, fmt.Errorf("creating generate command: %w", err) } cmd.AddCommand(gCmd) + cmd.AddCommand(NewMultiEnvCmd()) return cmd, nil } diff --git a/repocfg/project.go b/repocfg/project.go index eab05a3..acc1e8e 100644 --- a/repocfg/project.go +++ b/repocfg/project.go @@ -40,8 +40,7 @@ func ProjectsFrom(c Component, opts Options) ([]ExtRawProject, error) { Name: ptr(friendlyName(c.Path, v)), // The directory of this project relative to the repo root. - Dir: ptr(c.Path), - Workflow: ptr(friendlyName(c.Path, v)), + Dir: ptr(c.Path), }, } diff --git a/repocfg/project_test.go b/repocfg/project_test.go index 65bd72b..470c2bc 100644 --- a/repocfg/project_test.go +++ b/repocfg/project_test.go @@ -32,7 +32,6 @@ func Test_ProjectsFrom(t *testing.T) { Project: raw.Project{ Name: ptr("test-env"), Dir: ptr("test"), - Workflow: ptr("test-env"), Workspace: ptr("env"), TerraformVersion: ptr("8.8.8"), Autoplan: &raw.Autoplan{ diff --git a/repocfg/repocfg.go b/repocfg/repocfg.go index b811505..3e4ae39 100644 --- a/repocfg/repocfg.go +++ b/repocfg/repocfg.go @@ -15,7 +15,6 @@ type Options struct { Autoplan bool DefaultTerraformVersion string Parallel bool - MultiEnv bool UseWorkspaces bool } @@ -58,19 +57,6 @@ func NewRepoCfg(components []Component, opts Options) (*ExtRawRepoCfg, error) { repoCfg.Projects = append(repoCfg.Projects, projects...) - workflows := map[string]raw.Workflow{} - for _, c := range components { - generated, err := WorkflowsFrom(c, opts) - if err != nil { - return nil, fmt.Errorf("failed while creating workflows with component %+v: %w", c, err) - } - for _, wf := range generated { - workflows[wf.Name] = wf.Workflow - } - } - - repoCfg.Workflows = workflows - return repoCfg, nil } @@ -80,20 +66,9 @@ func (rc *ExtRawRepoCfg) MarshalYAML() (interface{}, error) { {Key: "automerge", Value: rc.Automerge}, {Key: "parallel_plan", Value: rc.ParallelPlan}, {Key: "parallel_apply", Value: rc.ParallelApply}, + {Key: "projects", Value: rc.Projects}, } - workflows := yaml.MapSlice{} - for name, wf := range rc.Workflows { - workflows = append(workflows, yaml.MapItem{ - Key: name, - Value: yaml.MapSlice{ - {Key: "plan", Value: wf.Plan}, - {Key: "apply", Value: wf.Apply}, - }, - }) - } - - m = append(m, yaml.MapItem{Key: "workflows", Value: workflows}) return m, nil } diff --git a/repocfg/repocfg_test.go b/repocfg/repocfg_test.go index 5ff92ca..90f3f09 100644 --- a/repocfg/repocfg_test.go +++ b/repocfg/repocfg_test.go @@ -29,60 +29,14 @@ func Test_NewFrom(t *testing.T) { Automerge: ptr(false), ParallelPlan: ptr(false), ParallelApply: ptr(false), - Workflows: map[string]raw.Workflow{ - "test-dev": { - Plan: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("init"), - }, - { - Map: map[string]map[string][]string{ - "plan": {"extra_args": []string{"-var-file=vars/dev.tfvars"}}, - }, - }, - }, - }, - Apply: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("apply"), - }, - }, - }, - }, - "test-stg": { - Plan: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("init"), - }, - { - Map: map[string]map[string][]string{ - "plan": {"extra_args": []string{"-var-file=vars/nested/stg.tfvars"}}, - }, - }, - }, - }, - Apply: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("apply"), - }, - }, - }, - }, - }, Projects: []raw.Project{ { - Name: ptr("test-dev"), - Dir: ptr("test"), - Workflow: ptr("test-dev"), + Name: ptr("test-dev"), + Dir: ptr("test"), }, { - Name: ptr("test-stg"), - Dir: ptr("test"), - Workflow: ptr("test-stg"), + Name: ptr("test-stg"), + Dir: ptr("test"), }, }, }, diff --git a/repocfg/util.go b/repocfg/util.go index d6814ee..3b32102 100644 --- a/repocfg/util.go +++ b/repocfg/util.go @@ -1,8 +1,6 @@ package repocfg import ( - "fmt" - "os" "path/filepath" "strings" ) @@ -10,7 +8,7 @@ import ( // Ptr returns a pointer to type T func ptr[T any](v T) *T { return &v } -// friendlyName creates a contextual name used for Atlantis projects and workflows +// friendlyName creates a contextual name used for Atlantis projects func friendlyName(path, environment string) string { name := []string{strings.ReplaceAll(path, "/", "-"), pathWithoutExtension(filepath.Base(environment))} return strings.TrimSuffix(strings.Join(name, "-"), "-") @@ -21,48 +19,3 @@ func friendlyName(path, environment string) string { func pathWithoutExtension(path string) string { return strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), filepath.Base(path)) } - -var ErrNoEnvVars error = fmt.Errorf("no matching prefixed environment variables found") - -// Generates the Atlantis multienv string within stages for multi-environment -// Terraform projects, e.g: -// -// EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3 -// -// Given a prefix for the environment name of the workflow, strips the -// environment name from existing environment vars while keeping their value. -// -// This is useful when you want to configure providers via environment variables -// on a per-environment basis. -// -// Example: -// -// DEV_AWS_ACCESS_KEY_ID="foo" -// DEV_AWS_SECRET_ACCESS_KEY="bar" -// -> -// AWS_ACCESS_KEY_ID=$DEV_AWS_ACCESS_KEY_ID -// AWS_SECRET_ACCESS_KEY=$DEV_AWS_SECRET_ACCESS_KEY -func prefixedEnviron(prefix string) (*string, error) { - prefix = strings.ToUpper(prefix) - if !strings.HasSuffix(prefix, "_") { - prefix += "_" - } - - strippedEnviron := []string{} - for _, v := range os.Environ() { - if strings.HasPrefix(v, prefix) { - // Limits the split to only two parts, separating the key from the first - // occurrence of '=', otherwise if the value contains '=' character(s) the - // string would be split into more than two parts. - name := strings.SplitN(v, "=", 2)[0] - - // Strips the prefix from the environment variable name, e.g. "DEV_" from - // "DEV_AWS_ACCESS_KEY_ID" and let it equal to the original environment variable - strippedEnviron = append(strippedEnviron, fmt.Sprintf("%s=$%s", strings.TrimPrefix(name, prefix), name)) - } - } - if len(strippedEnviron) == 0 { - return nil, ErrNoEnvVars - } - return ptr(fmt.Sprintf("echo %s", strings.Join(strippedEnviron, ","))), nil -} diff --git a/repocfg/util_test.go b/repocfg/util_test.go index a64450d..2ca935a 100644 --- a/repocfg/util_test.go +++ b/repocfg/util_test.go @@ -27,7 +27,7 @@ func Test_Ptr(t *testing.T) { } // Tests the friendlyName helper function. Given a path and an environment it -// should provide a contextual name to be used for Atlantis projects and workflows. +// should provide a contextual name to be used for Atlantis projects. func Test_FriendlyName(t *testing.T) { t.Parallel() diff --git a/repocfg/workflow.go b/repocfg/workflow.go deleted file mode 100644 index 543213f..0000000 --- a/repocfg/workflow.go +++ /dev/null @@ -1,175 +0,0 @@ -package repocfg - -import ( - "errors" - "fmt" - "path/filepath" - - "github.com/runatlantis/atlantis/server/core/config/raw" -) - -// ExtRawWorkflow extends the raw.Workflow type to add additional methods -// and fields -type ExtRawWorkflow struct { - Name string - Args *ExtraArgs - Workspace string - raw.Workflow -} - -// ExtraArgs is a type aliased map of extra arguments to be passed within a workflow -type ExtraArgs map[string][]string - -// hasArgs returns true if the component has a target (i.e. a Terraform variable file) -func hasArgs(c Component) bool { - return len(c.VarFiles) > 0 -} - -// Add generates the extra_args for the stage and adds them to the stage's Args -func (args *ExtraArgs) Add(v []string) { - (*args)["extra_args"] = v -} - -// relativeVarFile returns the path of the variable file v relative to -// the component's path. -func relativeVarFile(c, v string) (*string, error) { - absComponent, err := filepath.Abs(c) - if err != nil { - return nil, fmt.Errorf("absolute path for component %s: %s", c, err) - } - - absVarFile, err := filepath.Abs(v) - if err != nil { - return nil, fmt.Errorf("absolute path for %s: %s", v, err) - } - - rel, err := filepath.Rel(absComponent, absVarFile) - if err != nil { - return nil, fmt.Errorf("relative path for %s: %s", v, err) - } - return &rel, nil -} - -// WorkflowsFrom creates new Atlantis workflows from a Terraform component -// and calls the stage methods to generate all of its fields. -func WorkflowsFrom(c Component, opts Options) ([]ExtRawWorkflow, error) { - var workflows []ExtRawWorkflow - for _, v := range c.VarFiles { - - wf := ExtRawWorkflow{ - Name: friendlyName(c.Path, v), - Workflow: raw.Workflow{ - Plan: &raw.Stage{}, - Apply: &raw.Stage{}, - }, - Args: &ExtraArgs{}, - Workspace: pathWithoutExtension(v), - } - - if hasArgs(c) { - rel, err := relativeVarFile(c.Path, v) - if err != nil { - return nil, fmt.Errorf("relative variable file for %s: %s", v, err) - } - - wf.Args.Add([]string{"-var-file=" + *rel}) - } - - if opts.MultiEnv { - err := wf.AddMultiEnv(v) - if err != nil { - return nil, fmt.Errorf("adding multienv for %s: %s", v, err) - } - } - - if opts.UseWorkspaces { - wf.AddWorkspace() - } - - wf.PlanStage(opts) - wf.ApplyStage(opts) - - err := wf.Validate() - if err != nil { - return nil, fmt.Errorf("workflow validation failed for %s: %s", wf.Name, err) - } - workflows = append(workflows, wf) - } - return workflows, nil -} - -// PlanStage generates the plan stage for the workflow -func (wf *ExtRawWorkflow) PlanStage(opts Options) { - if wf.Plan == nil { - wf.Plan = new(raw.Stage) - } - - init := raw.Step{ - Key: ptr("init"), - } - wf.Plan.Steps = append(wf.Plan.Steps, init) - - if len(*wf.Args) > 0 { - wf.Plan.Steps = append(wf.Plan.Steps, raw.Step{ - Map: map[string]map[string][]string{ - "plan": *wf.Args, - }, - }) - } else { - wf.Plan.Steps = append(wf.Plan.Steps, raw.Step{ - Key: ptr("plan"), - }) - } -} - -// ApplyStage generates the apply stage for the workflow -// -// Unlike the plan stage, the apply stage doesn't need extra args for -var-file, -// as it uses the planfile generated by the plan stage. -func (wf *ExtRawWorkflow) ApplyStage(opts Options) { - if wf.Apply == nil { - wf.Apply = new(raw.Stage) - } - - wf.Apply.Steps = append(wf.Apply.Steps, raw.Step{ - Key: ptr("apply"), - }) -} - -// AddWorkspace sets TF_WORKSPACE in the plan and apply stages -// to the value of the workspace from the project configuration. -// -// This is needed because Atlantis will use the default workspace -// which doesn't exist. -func (wf *ExtRawWorkflow) AddWorkspace() { - for _, stg := range []*raw.Stage{wf.Plan, wf.Apply} { - stg.Steps = append(stg.Steps, raw.Step{ - EnvOrRun: map[string]map[string]string{ - "env": { - "name": "TF_WORKSPACE", - "value": wf.Workspace, - }, - }, - }) - } -} - -// AddMultiEnv adds the multienv step to the plan and apply stages -func (wf *ExtRawWorkflow) AddMultiEnv(env string) error { - environ, err := prefixedEnviron(pathWithoutExtension(env)) - if errors.Is(err, ErrNoEnvVars) { - return nil - } - if err != nil { - return fmt.Errorf("adding multienv for environment %s: %w", env, err) - } - - for _, stg := range []*raw.Stage{wf.Plan, wf.Apply} { - stg.Steps = append(stg.Steps, raw.Step{ - StringVal: map[string]string{ - "multienv": *environ, - }, - }) - } - return nil -} diff --git a/repocfg/workflow_test.go b/repocfg/workflow_test.go deleted file mode 100644 index 140d8c6..0000000 --- a/repocfg/workflow_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package repocfg - -import ( - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/runatlantis/atlantis/server/core/config/raw" -) - -// Tests creating a new workflow from a Terraform component -func Test_WorkflowsFrom(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - component Component - want []ExtRawWorkflow - }{ - { - name: "new-workflow", - component: Component{ - Path: "test", - VarFiles: []string{"test/vars/dev.tfvars", "test/vars/stg.tfvars"}, - }, - want: []ExtRawWorkflow{ - { - Name: "test-dev", - Args: &ExtraArgs{ - "extra_args": []string{"-var-file=vars/dev.tfvars"}, - }, - Workspace: "dev", - Workflow: raw.Workflow{ - Plan: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("init"), - }, - { - Map: map[string]map[string][]string{ - "plan": {"extra_args": []string{"-var-file=vars/dev.tfvars"}}, - }, - }, - }, - }, - Apply: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("apply"), - }, - }, - }, - }, - }, - { - Name: "test-stg", - Args: &ExtraArgs{ - "extra_args": []string{"-var-file=vars/stg.tfvars"}, - }, - Workspace: "stg", - Workflow: raw.Workflow{ - Plan: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("init"), - }, - { - Map: map[string]map[string][]string{ - "plan": {"extra_args": []string{"-var-file=vars/stg.tfvars"}}, - }, - }, - }, - }, - Apply: &raw.Stage{ - Steps: []raw.Step{ - { - Key: ptr("apply"), - }, - }, - }, - }, - }, - }, - }, - } - - for _, tc := range tests { - got, err := WorkflowsFrom(tc.component, Options{}) - if err != nil { - t.Errorf("WorkflowsFrom(): %s", err) - } - if !cmp.Equal(got, tc.want) { - t.Errorf(`WorkflowFrom() - diff %s`, cmp.Diff(got, tc.want)) - } - } -}