mirror of
https://github.com/3bbbeau/tfvars-atlantis-config.git
synced 2025-06-30 05:36:01 +00:00
Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
78
README.md
78
README.md
@ -8,14 +8,14 @@
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
### CLI
|
### CLI
|
||||||
tfvars-atlantis-config generate --use-workspaces --automerge --autoplan --parallel --output=atlantis.yaml
|
tfvars-atlantis-config generate --automerge --autoplan --parallel --output=atlantis.yaml
|
||||||
|
|
||||||
### Atlantis Server Side Config
|
### Atlantis Server Side Config
|
||||||
```yaml
|
```yaml
|
||||||
repos:
|
repos:
|
||||||
- id: /.*/
|
- id: /.*/
|
||||||
pre_workflow_hooks:
|
pre_workflow_hooks:
|
||||||
- run: tfvars-atlantis-config generate --use-workspaces --automerge --autoplan --parallel --output=atlantis.yaml
|
- run: tfvars-atlantis-config generate --automerge --autoplan --parallel --output=atlantis.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +38,6 @@ environment levels.
|
|||||||
my-terraform
|
my-terraform
|
||||||
├── main.tf
|
├── main.tf
|
||||||
├── dev.tfvars
|
├── dev.tfvars
|
||||||
├── prod.tfvars
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generates the following Atlantis configuration:
|
#### Generates the following Atlantis configuration:
|
||||||
@ -51,7 +50,7 @@ parallel_apply: true
|
|||||||
projects:
|
projects:
|
||||||
- name: my-terraform-dev
|
- name: my-terraform-dev
|
||||||
dir: my-terraform
|
dir: my-terraform
|
||||||
workspace: dev
|
workflow: my-terraform-dev
|
||||||
autoplan:
|
autoplan:
|
||||||
when_modified:
|
when_modified:
|
||||||
- '*.tf'
|
- '*.tf'
|
||||||
@ -59,12 +58,31 @@ projects:
|
|||||||
enabled: true
|
enabled: true
|
||||||
- name: my-terraform-prod
|
- name: my-terraform-prod
|
||||||
dir: my-terraform
|
dir: my-terraform
|
||||||
workspace: prod
|
workflow: my-terraform-prod
|
||||||
autoplan:
|
autoplan:
|
||||||
when_modified:
|
when_modified:
|
||||||
- '*.tf'
|
- '*.tf'
|
||||||
- prod.tfvars
|
- prod.tfvars
|
||||||
enabled: true
|
enabled: true
|
||||||
|
workflows:
|
||||||
|
my-terraform-dev:
|
||||||
|
plan:
|
||||||
|
steps:
|
||||||
|
- init
|
||||||
|
- plan:
|
||||||
|
extra_args:
|
||||||
|
- -var-file=dev.tfvars
|
||||||
|
apply:
|
||||||
|
- apply
|
||||||
|
my-terraform-prod:
|
||||||
|
plan:
|
||||||
|
steps:
|
||||||
|
- init
|
||||||
|
- plan:
|
||||||
|
extra_args:
|
||||||
|
- -var-file=prod.tfvars
|
||||||
|
apply:
|
||||||
|
- apply
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why you should use it?
|
## Why you should use it?
|
||||||
@ -85,56 +103,8 @@ runtime.
|
|||||||
| `--automerge` | Enable auto merge. | false |
|
| `--automerge` | Enable auto merge. | false |
|
||||||
| `--autoplan` | Enable auto plan. | false |
|
| `--autoplan` | Enable auto plan. | false |
|
||||||
| `--default-terraform-version` | Default terraform version to run for Atlantis. Default is determined by the Terraform version constraints. | "" |
|
| `--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` |
|
| `--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 |
|
| `--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. | `.` |
|
| `--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 |
|
| `--use-workspaces` | Whether to use Terraform workspaces for projects. | false |
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
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"`
|
|
||||||
- `STG_AWS_ACCESS_KEY="..."` -> `AWS_ACCESS_KEY="..."`
|
|
||||||
|
|
||||||
..and so on.
|
|
||||||
|
|
||||||
Reference:
|
|
||||||
[Multienv](https://www.runatlantis.io/docs/custom-workflows.html#step)
|
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/3bbbeau/tfvars-atlantis-config/logger"
|
"github.com/3bbbeau/tfvars-atlantis-config/logger"
|
||||||
@ -39,6 +38,7 @@ func NewFlags() (*Flags, error) {
|
|||||||
AutoPlan: false,
|
AutoPlan: false,
|
||||||
DefaultTerraformVersion: "",
|
DefaultTerraformVersion: "",
|
||||||
Root: pwd,
|
Root: pwd,
|
||||||
|
MultiEnv: false,
|
||||||
Output: "",
|
Output: "",
|
||||||
Parallel: false,
|
Parallel: false,
|
||||||
UseWorkspaces: false,
|
UseWorkspaces: false,
|
||||||
@ -52,6 +52,7 @@ 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().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.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().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().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")
|
cmd.Flags().BoolVar(&flags.UseWorkspaces, "use-workspaces", flags.UseWorkspaces, "Use workspaces for projects. Default is disabled")
|
||||||
|
|
||||||
@ -66,6 +67,7 @@ func (flags *Flags) toOptions() repocfg.Options {
|
|||||||
Automerge: flags.AutoMerge,
|
Automerge: flags.AutoMerge,
|
||||||
Autoplan: flags.AutoPlan,
|
Autoplan: flags.AutoPlan,
|
||||||
DefaultTerraformVersion: flags.DefaultTerraformVersion,
|
DefaultTerraformVersion: flags.DefaultTerraformVersion,
|
||||||
|
MultiEnv: flags.MultiEnv,
|
||||||
Parallel: flags.Parallel,
|
Parallel: flags.Parallel,
|
||||||
UseWorkspaces: flags.UseWorkspaces,
|
UseWorkspaces: flags.UseWorkspaces,
|
||||||
}
|
}
|
||||||
@ -169,7 +171,7 @@ const (
|
|||||||
func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
|
func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
|
||||||
logger := logger.FromContext(ctx)
|
logger := logger.FromContext(ctx)
|
||||||
|
|
||||||
discovered := []repocfg.Component{}
|
var discovered []repocfg.Component
|
||||||
|
|
||||||
// Walk the directory tree from the root
|
// Walk the directory tree from the root
|
||||||
err := filepath.Walk(flags.Root, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(flags.Root, func(path string, info os.FileInfo, err error) error {
|
||||||
@ -181,6 +183,12 @@ func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
|
|||||||
if !info.IsDir() && strings.HasSuffix(info.Name(), TF_EXT) {
|
if !info.IsDir() && strings.HasSuffix(info.Name(), TF_EXT) {
|
||||||
logger.Debug(fmt.Sprintf("found .tf file: %s", path))
|
logger.Debug(fmt.Sprintf("found .tf file: %s", path))
|
||||||
|
|
||||||
|
// Add the directory of the Terraform component as the parent
|
||||||
|
parent, err := filepath.Rel(flags.Root, filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Each edge should be a path containing Terraform variables files
|
// Each edge should be a path containing Terraform variables files
|
||||||
// relative to the Terraform component
|
// relative to the Terraform component
|
||||||
err = filepath.Walk(filepath.Dir(path), func(subpath string, subinfo os.FileInfo, suberr error) error {
|
err = filepath.Walk(filepath.Dir(path), func(subpath string, subinfo os.FileInfo, suberr error) error {
|
||||||
@ -190,23 +198,19 @@ func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
|
|||||||
|
|
||||||
// Filter out Terraform variable files
|
// Filter out Terraform variable files
|
||||||
if !subinfo.IsDir() && (strings.HasSuffix(subinfo.Name(), TFVARS_EXT) || strings.HasSuffix(subinfo.Name(), TFVARS_JSON_EXT)) {
|
if !subinfo.IsDir() && (strings.HasSuffix(subinfo.Name(), TFVARS_EXT) || strings.HasSuffix(subinfo.Name(), TFVARS_JSON_EXT)) {
|
||||||
parent := subpath
|
|
||||||
// Ignore nested Terraform variable files that might belong to nested components
|
// Ignore nested Terraform variable files that might belong to nested components
|
||||||
if filepath.Dir(subpath) != filepath.Dir(path) && !tfExistsInDir(filepath.Dir(subpath)) {
|
if filepath.Dir(subpath) != filepath.Dir(path) && tfExistsInDir(filepath.Dir(subpath)) {
|
||||||
logger.Sugar().Debugf(`ignoring nested %s: %s
|
logger.Sugar().Debugf(`ignoring nested %s: %s
|
||||||
which belongs to the component at: %s
|
which belongs to the component at: %s
|
||||||
not: %s`, filepath.Ext(subpath), subpath, filepath.Dir(subpath), filepath.Dir(path),
|
not: %s`, filepath.Ext(subpath), subpath, filepath.Dir(subpath), filepath.Dir(path),
|
||||||
)
|
)
|
||||||
parent = path
|
|
||||||
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
|
||||||
relParent, err := filepath.Rel(flags.Root, filepath.Dir(parent))
|
logger.Sugar().Debugf("component parent is %s", parent)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Sugar().Debugf("component parent is %s", relParent)
|
|
||||||
found := repocfg.Component{
|
found := repocfg.Component{
|
||||||
Path: relParent,
|
Path: parent,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Sugar().Debugf("found %s file: %s", filepath.Ext(subpath), subpath)
|
logger.Sugar().Debugf("found %s file: %s", filepath.Ext(subpath), subpath)
|
||||||
@ -219,27 +223,8 @@ func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
|
|||||||
logger.Sugar().Debugf("component %s has var file %s", parent, child)
|
logger.Sugar().Debugf("component %s has var file %s", parent, child)
|
||||||
found.VarFiles = append(found.VarFiles, child)
|
found.VarFiles = append(found.VarFiles, child)
|
||||||
|
|
||||||
// Check if the component already exists in the slice
|
discovered = append(discovered, found)
|
||||||
// If it does, ensure the var file is not a duplicate and append it.
|
|
||||||
exists := false
|
|
||||||
for idx, component := range discovered {
|
|
||||||
if component.Path == found.Path {
|
|
||||||
exists = true
|
|
||||||
for _, varFile := range found.VarFiles {
|
|
||||||
if !slices.Contains(component.VarFiles, varFile) {
|
|
||||||
discovered[idx].VarFiles = append(discovered[idx].VarFiles, varFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the component does not exist in the slice, then
|
|
||||||
// it can be treated as new component.
|
|
||||||
if !exists {
|
|
||||||
discovered = append(discovered, found)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@ -35,7 +35,6 @@ func New() (*cobra.Command, error) {
|
|||||||
return nil, fmt.Errorf("creating generate command: %w", err)
|
return nil, fmt.Errorf("creating generate command: %w", err)
|
||||||
}
|
}
|
||||||
cmd.AddCommand(gCmd)
|
cmd.AddCommand(gCmd)
|
||||||
cmd.AddCommand(NewMultiEnvCmd())
|
|
||||||
|
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,8 @@ func ProjectsFrom(c Component, opts Options) ([]ExtRawProject, error) {
|
|||||||
Name: ptr(friendlyName(c.Path, v)),
|
Name: ptr(friendlyName(c.Path, v)),
|
||||||
|
|
||||||
// The directory of this project relative to the repo root.
|
// The directory of this project relative to the repo root.
|
||||||
Dir: ptr(c.Path),
|
Dir: ptr(c.Path),
|
||||||
|
Workflow: ptr(friendlyName(c.Path, v)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ func Test_ProjectsFrom(t *testing.T) {
|
|||||||
Project: raw.Project{
|
Project: raw.Project{
|
||||||
Name: ptr("test-env"),
|
Name: ptr("test-env"),
|
||||||
Dir: ptr("test"),
|
Dir: ptr("test"),
|
||||||
|
Workflow: ptr("test-env"),
|
||||||
Workspace: ptr("env"),
|
Workspace: ptr("env"),
|
||||||
TerraformVersion: ptr("8.8.8"),
|
TerraformVersion: ptr("8.8.8"),
|
||||||
Autoplan: &raw.Autoplan{
|
Autoplan: &raw.Autoplan{
|
||||||
|
@ -15,6 +15,7 @@ type Options struct {
|
|||||||
Autoplan bool
|
Autoplan bool
|
||||||
DefaultTerraformVersion string
|
DefaultTerraformVersion string
|
||||||
Parallel bool
|
Parallel bool
|
||||||
|
MultiEnv bool
|
||||||
UseWorkspaces bool
|
UseWorkspaces bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +58,19 @@ func NewRepoCfg(components []Component, opts Options) (*ExtRawRepoCfg, error) {
|
|||||||
|
|
||||||
repoCfg.Projects = append(repoCfg.Projects, projects...)
|
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
|
return repoCfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,9 +80,20 @@ func (rc *ExtRawRepoCfg) MarshalYAML() (interface{}, error) {
|
|||||||
{Key: "automerge", Value: rc.Automerge},
|
{Key: "automerge", Value: rc.Automerge},
|
||||||
{Key: "parallel_plan", Value: rc.ParallelPlan},
|
{Key: "parallel_plan", Value: rc.ParallelPlan},
|
||||||
{Key: "parallel_apply", Value: rc.ParallelApply},
|
{Key: "parallel_apply", Value: rc.ParallelApply},
|
||||||
|
|
||||||
{Key: "projects", Value: rc.Projects},
|
{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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
@ -29,14 +29,60 @@ func Test_NewFrom(t *testing.T) {
|
|||||||
Automerge: ptr(false),
|
Automerge: ptr(false),
|
||||||
ParallelPlan: ptr(false),
|
ParallelPlan: ptr(false),
|
||||||
ParallelApply: 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{
|
Projects: []raw.Project{
|
||||||
{
|
{
|
||||||
Name: ptr("test-dev"),
|
Name: ptr("test-dev"),
|
||||||
Dir: ptr("test"),
|
Dir: ptr("test"),
|
||||||
|
Workflow: ptr("test-dev"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: ptr("test-stg"),
|
Name: ptr("test-stg"),
|
||||||
Dir: ptr("test"),
|
Dir: ptr("test"),
|
||||||
|
Workflow: ptr("test-stg"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package repocfg
|
package repocfg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -8,16 +10,9 @@ import (
|
|||||||
// Ptr returns a pointer to type T
|
// Ptr returns a pointer to type T
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
// friendlyName creates a contextual name used for Atlantis projects
|
// friendlyName creates a contextual name used for Atlantis projects and workflows
|
||||||
func friendlyName(path, varFile string) string {
|
func friendlyName(path, environment string) string {
|
||||||
environment := pathWithoutExtension(filepath.Base(varFile))
|
name := []string{strings.ReplaceAll(path, "/", "-"), pathWithoutExtension(filepath.Base(environment))}
|
||||||
|
|
||||||
// avoid constructing a joined path if the context is the current directory
|
|
||||||
if filepath.Base(path) == "." {
|
|
||||||
return environment
|
|
||||||
}
|
|
||||||
|
|
||||||
name := []string{strings.ReplaceAll(path, "/", "-"), environment}
|
|
||||||
return strings.TrimSuffix(strings.Join(name, "-"), "-")
|
return strings.TrimSuffix(strings.Join(name, "-"), "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,3 +21,48 @@ func friendlyName(path, varFile string) string {
|
|||||||
func pathWithoutExtension(path string) string {
|
func pathWithoutExtension(path string) string {
|
||||||
return strings.TrimSuffix(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), filepath.Base(path))
|
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
|
||||||
|
}
|
||||||
|
@ -27,7 +27,7 @@ func Test_Ptr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tests the friendlyName helper function. Given a path and an environment it
|
// Tests the friendlyName helper function. Given a path and an environment it
|
||||||
// should provide a contextual name to be used for Atlantis projects.
|
// should provide a contextual name to be used for Atlantis projects and workflows.
|
||||||
func Test_FriendlyName(t *testing.T) {
|
func Test_FriendlyName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
175
repocfg/workflow.go
Normal file
175
repocfg/workflow.go
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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
|
||||||
|
}
|
96
repocfg/workflow_test.go
Normal file
96
repocfg/workflow_test.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user