├── pkg ├── tfcw │ ├── workspaces_test.go │ ├── runs_test.go │ ├── variables_test.go │ ├── client_test.go │ ├── client.go │ ├── workspaces.go │ ├── runs.go │ └── variables.go ├── schemas │ ├── env.go │ ├── vault.go │ ├── defaults.go │ ├── tfc.go │ ├── variable_test.go │ ├── s5.go │ ├── variable.go │ ├── config.go │ └── config_test.go ├── providers │ ├── env │ │ ├── env.go │ │ └── env_test.go │ ├── s5 │ │ ├── aes.go │ │ ├── aws.go │ │ ├── gcp.go │ │ ├── vault.go │ │ ├── pgp.go │ │ ├── client.go │ │ ├── aes_test.go │ │ ├── aws_test.go │ │ ├── vault_test.go │ │ ├── gcp_test.go │ │ ├── client_test.go │ │ └── pgp_test.go │ └── vault │ │ ├── vault.go │ │ └── vault_test.go ├── functions │ ├── env_test.go │ └── env.go └── terraform │ ├── terraform_test.go │ └── terraform.go ├── .gitignore ├── cmd └── tfcw │ └── main.go ├── test └── tf-stack │ └── terraform.tf ├── .github ├── dependabot.yml ├── workflows │ ├── test.yml │ └── release.yml └── prerelease.sh ├── Dockerfile ├── internal ├── cli │ ├── cli_test.go │ ├── flags.go │ └── cli.go └── cmd │ ├── render.go │ ├── utils_test.go │ ├── run_test.go │ ├── workspace.go │ ├── run.go │ └── utils.go ├── docs ├── examples │ ├── provider_env.md │ ├── using_ttls.md │ ├── provider_vault.md │ ├── provider_s5_aes.md │ ├── provider_s5_gcp_kms.md │ ├── provider_s5_vault.md │ ├── provider_s5_pgp.md │ ├── provider_s5_aws_kms.md │ ├── workspace_configuration.md │ └── provider_vault_multi_keys.md └── configuration_syntax.md ├── .revive.toml ├── Makefile ├── .goreleaser.pre.yml ├── .goreleaser.yml ├── go.mod ├── CHANGELOG.md ├── LICENSE └── README.md /pkg/tfcw/workspaces_test.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | // TODO: Implement! 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.out 2 | dist 3 | tfcw 4 | tfcw.hcl 5 | *.env 6 | *.tfvars 7 | vendor 8 | .terraform 9 | runid 10 | !/*/tfcw 11 | -------------------------------------------------------------------------------- /pkg/schemas/env.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | // Env is a provider type 4 | type Env struct { 5 | Variable string `hcl:"variable"` 6 | } 7 | -------------------------------------------------------------------------------- /cmd/tfcw/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/tfcw/internal/cli" 7 | ) 8 | 9 | var version = "" 10 | 11 | func main() { 12 | cli.Run(version, os.Args) 13 | } 14 | -------------------------------------------------------------------------------- /test/tf-stack/terraform.tf: -------------------------------------------------------------------------------- 1 | provider "local" { 2 | version = "~> 1.4.0" 3 | } 4 | 5 | variable "credentials" {} 6 | 7 | resource "local_file" "credentials_file" { 8 | filename = "./credentials" 9 | file_permission = "0600" 10 | content = var.credentials 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: docker 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | time: "10:00" 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /pkg/providers/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/tfcw/pkg/schemas" 7 | ) 8 | 9 | // Client is a basic struct in order to support provider 10 | // related functions 11 | type Client struct{} 12 | 13 | // GetValue returns a value from an environment variable 14 | func (c *Client) GetValue(e *schemas.Env) string { 15 | return os.Getenv(e.Variable) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/providers/env/env_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetValue(t *testing.T) { 12 | os.Setenv("TEST_ENV", "foo") 13 | 14 | c := &Client{} 15 | e := &schemas.Env{ 16 | Variable: "TEST_ENV", 17 | } 18 | 19 | assert.Equal(t, "foo", c.GetValue(e)) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/schemas/vault.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | // Vault is a provider type 4 | type Vault struct { 5 | Address *string `hcl:"address"` 6 | Token *string `hcl:"token"` 7 | Method *string `hcl:"method"` 8 | Params *map[string]string `hcl:"params"` 9 | Path *string `hcl:"path"` 10 | Key *string `hcl:"key"` 11 | Keys *map[string]string `hcl:"keys"` 12 | Values map[string]string 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## 2 | # BUILD CONTAINER 3 | ## 4 | 5 | FROM alpine:3.15.0 as certs 6 | 7 | RUN \ 8 | apk add --no-cache ca-certificates 9 | 10 | ## 11 | # RELEASE CONTAINER 12 | ## 13 | 14 | FROM busybox:1.35.0-glibc 15 | 16 | WORKDIR / 17 | 18 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 19 | COPY tfcw /usr/local/bin/ 20 | 21 | # Run as nobody user 22 | USER 65534 23 | 24 | EXPOSE 8080 25 | 26 | ENTRYPOINT ["/usr/local/bin/tfcw"] 27 | CMD [] 28 | -------------------------------------------------------------------------------- /internal/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | // +build !darwin 3 | 4 | package cli 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRun(t *testing.T) { 14 | assert.NotPanics(t, func() { Run("0.0.0", []string{"vac", "--version"}) }) 15 | } 16 | 17 | func TestNewApp(t *testing.T) { 18 | app := NewApp("0.0.0", time.Now()) 19 | assert.Equal(t, "tfcw", app.Name) 20 | assert.Equal(t, "0.0.0", app.Version) 21 | } 22 | -------------------------------------------------------------------------------- /docs/examples/provider_env.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a environment variable on the system 2 | 3 | This one is as easy as: 4 | 5 | ```hcl 6 | tfc { 7 | organization = "acme" 8 | workspace { 9 | name = "foo" 10 | } 11 | } 12 | 13 | tfvar "my_variable" { 14 | env { 15 | variable = "FOO" 16 | } 17 | } 18 | ``` 19 | 20 | This will provision the value of the `FOO` environment variable into a **Terraform variable** named `my_variable`. 21 | -------------------------------------------------------------------------------- /pkg/schemas/defaults.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | // Defaults can handle default values for some providers 4 | type Defaults struct { 5 | Variable *VariableDefaults `hcl:"var,block"` 6 | Vault *Vault `hcl:"vault,block"` 7 | S5 *S5 `hcl:"s5,block"` 8 | } 9 | 10 | // VariableDefaults can handle default values for variables 11 | type VariableDefaults struct { 12 | Sensitive *bool `hcl:"sensitive"` 13 | HCL *bool `hcl:"hcl"` 14 | TTL *string `hcl:"ttl"` 15 | } 16 | -------------------------------------------------------------------------------- /pkg/providers/s5/aes.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | func (c *Client) getCipherEngineAES(v *schemas.S5) (cipher.Engine, error) { 11 | if v.CipherEngineAES != nil && v.CipherEngineAES.Key != nil { 12 | return cipher.NewAESClient(*v.CipherEngineAES.Key) 13 | } 14 | 15 | if c.CipherEngineAES != nil && c.CipherEngineAES.Key != nil { 16 | return cipher.NewAESClient(*c.CipherEngineAES.Key) 17 | } 18 | 19 | return cipher.NewAESClient(os.Getenv("S5_AES_KEY")) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/providers/s5/aws.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | func (c *Client) getCipherEngineAWS(v *schemas.S5) (cipher.Engine, error) { 11 | if v.CipherEngineAWS != nil && v.CipherEngineAWS.KmsKeyArn != nil { 12 | return cipher.NewAWSClient(*v.CipherEngineAWS.KmsKeyArn) 13 | } 14 | 15 | if c.CipherEngineAWS != nil && c.CipherEngineAWS.KmsKeyArn != nil { 16 | return cipher.NewAWSClient(*c.CipherEngineAWS.KmsKeyArn) 17 | } 18 | 19 | return cipher.NewAWSClient(os.Getenv("S5_AWS_KMS_KEY_ARN")) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/providers/s5/gcp.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | func (c *Client) getCipherEngineGCP(v *schemas.S5) (cipher.Engine, error) { 11 | if v.CipherEngineGCP != nil && v.CipherEngineGCP.KmsKeyName != nil { 12 | return cipher.NewGCPClient(*v.CipherEngineGCP.KmsKeyName) 13 | } 14 | 15 | if c.CipherEngineGCP != nil && c.CipherEngineGCP.KmsKeyName != nil { 16 | return cipher.NewGCPClient(*c.CipherEngineGCP.KmsKeyName) 17 | } 18 | 19 | return cipher.NewGCPClient(os.Getenv("S5_GCP_KMS_KEY_NAME")) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/providers/s5/vault.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | func (c *Client) getCipherEngineVault(v *schemas.S5) (cipher.Engine, error) { 11 | if v.CipherEngineVault != nil && v.CipherEngineVault.TransitKey != nil { 12 | return cipher.NewVaultClient(*v.CipherEngineVault.TransitKey) 13 | } 14 | 15 | if c.CipherEngineVault != nil && c.CipherEngineVault.TransitKey != nil { 16 | return cipher.NewVaultClient(*c.CipherEngineVault.TransitKey) 17 | } 18 | return cipher.NewVaultClient(os.Getenv("S5_VAULT_TRANSIT_KEY")) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/tfcw/runs_test.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | tfc "github.com/hashicorp/go-tfe" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCreateRunWorkspaceOperationsValue(t *testing.T) { 12 | cfg := getTestConfig() 13 | c, err := NewClient(cfg) 14 | assert.NoError(t, err) 15 | w := &tfc.Workspace{} 16 | assert.Equal(t, fmt.Errorf("remote operations must be enabled on the workspace"), c.CreateRun(cfg, w, &TFCCreateRunOptions{})) 17 | 18 | w.Operations = true 19 | assert.NotEqual(t, fmt.Errorf("remote operations must be enabled on the workspace"), c.CreateRun(cfg, w, &TFCCreateRunOptions{})) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/functions/env_test.go: -------------------------------------------------------------------------------- 1 | package functions 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/zclconf/go-cty/cty" 10 | ) 11 | 12 | func TestFunctionEnv(t *testing.T) { 13 | tests := []struct { 14 | Variable cty.Value 15 | Value cty.Value 16 | }{ 17 | { 18 | cty.StringVal("FOO"), 19 | cty.StringVal("BAR"), 20 | }, 21 | } 22 | 23 | for _, test := range tests { 24 | t.Run(fmt.Sprintf("env(%#v)", test.Variable.AsString()), func(t *testing.T) { 25 | os.Setenv(test.Variable.AsString(), test.Value.AsString()) 26 | 27 | v, _ := Env(test.Variable) 28 | assert.Equal(t, test.Value, v) 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 1 5 | warningCode = 1 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.cyclomatic] 11 | arguments = [25] 12 | [rule.dot-imports] 13 | [rule.error-return] 14 | [rule.error-strings] 15 | [rule.error-naming] 16 | [rule.exported] 17 | [rule.if-return] 18 | [rule.increment-decrement] 19 | [rule.var-naming] 20 | [rule.var-declaration] 21 | [rule.package-comments] 22 | [rule.range] 23 | [rule.receiver-naming] 24 | [rule.time-naming] 25 | [rule.unexported-return] 26 | [rule.indent-error-flow] 27 | [rule.errorf] 28 | [rule.empty-block] 29 | [rule.superfluous-else] 30 | [rule.unused-parameter] 31 | [rule.unreachable-code] 32 | [rule.redefines-builtin-id] -------------------------------------------------------------------------------- /pkg/functions/env.go: -------------------------------------------------------------------------------- 1 | package functions 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/zclconf/go-cty/cty" 7 | "github.com/zclconf/go-cty/cty/function" 8 | ) 9 | 10 | // EnvFunction constructs a function that looks up an EnvironmentVariable given the 11 | // variable name 12 | var EnvFunction = function.New(&function.Spec{ 13 | Params: []function.Parameter{ 14 | { 15 | Name: "envvar", 16 | Type: cty.String, 17 | }, 18 | }, 19 | Type: function.StaticReturnType(cty.String), 20 | Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { 21 | envvar := args[0].AsString() 22 | return cty.StringVal(os.Getenv(envvar)), nil 23 | }, 24 | }) 25 | 26 | // Env performs a function call to EnvFunction, useful for testing 27 | func Env(envvar cty.Value) (cty.Value, error) { 28 | return EnvFunction.Call([]cty.Value{envvar}) 29 | } 30 | -------------------------------------------------------------------------------- /docs/examples/using_ttls.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration with a defined Time To Live (TTL) 2 | 3 | You can leverage the `ttl` field on your variables to make TFCW more efficient with the management of your variables. 4 | 5 | ```hcl 6 | defaults { 7 | ttl = "15m" 8 | } 9 | 10 | tfvar "my_variable" { 11 | vault { 12 | path = "secret/mysecret" 13 | key = "foo" 14 | } 15 | } 16 | 17 | envvar "my_other_variable" { 18 | ttl = "1h" 19 | vault { 20 | path = "secret/mysecret" 21 | key = "bar" 22 | } 23 | } 24 | ``` 25 | 26 | With this configuration, TFCW will only update `my_variable` **after 15 minutes** and `my_other_variable` **after an hour**. 27 | 28 | As a rule of thumb, be cautious and use values lower than the actual expiration of the values in order to leave enough time to your Terraform run to execute successfully. 29 | -------------------------------------------------------------------------------- /pkg/schemas/tfc.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | // TFC handles Terraform Cloud related configuration 4 | type TFC struct { 5 | Address *string `hcl:"address"` 6 | Token *string `hcl:"token"` 7 | Organization *string `hcl:"organization"` 8 | Workspace *Workspace `hcl:"workspace,block"` 9 | 10 | WorkspaceAutoCreate *bool `hcl:"workspace-auto-create"` 11 | PurgeUnmanagedVariables *bool `hcl:"purge-unmanaged-variables"` 12 | } 13 | 14 | // Workspace is used to refer to and configure the workspace 15 | type Workspace struct { 16 | Name *string `hcl:"name"` 17 | Operations *bool `hcl:"operations"` 18 | AutoApply *bool `hcl:"auto-apply"` 19 | TerraformVersion *string `hcl:"terraform-version"` 20 | WorkingDirectory *string `hcl:"working-directory"` 21 | SSHKey *string `hcl:"ssh-key"` 22 | } 23 | -------------------------------------------------------------------------------- /docs/examples/provider_vault.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a Vault secret 2 | 3 | We consider here that the [Vault token](https://learn.hashicorp.com/vault/getting-started/authentication) 4 | has been made available either through the `VAULT_TOKEN` environment variable or at `~/.vault-token` 5 | 6 | ```hcl 7 | tfc { 8 | organization = "acme" 9 | workspace { 10 | name = "foo" 11 | } 12 | } 13 | 14 | defaults { 15 | vault { 16 | address = "https://vault.acme.local" 17 | } 18 | } 19 | 20 | tfvar "my_variable" { 21 | vault { 22 | path = "secret/mysecret" 23 | key = "foo" 24 | } 25 | } 26 | ``` 27 | 28 | You can also override all the parameters on a per secret basis 29 | 30 | ```hcl 31 | envvar "my_other_variable" { 32 | vault { 33 | // In here you can optionally override all the defaults values 34 | address = "https://alt-vault.acme.local" 35 | token = "alternative-token" 36 | method = "write" 37 | // ... 38 | 39 | path = "secret/mysecret" 40 | key = "bar" 41 | } 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - 'v[0-9]+.[0-9]+.[0-9]+' 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu-20.04 20 | - macos-11.0 21 | - windows-2022 22 | 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v2 28 | 29 | - name: Install Go 30 | uses: actions/setup-go@v2 31 | with: 32 | go-version: 1.17 33 | 34 | - name: Lint 35 | if: ${{ matrix.os == 'ubuntu-20.04' }} 36 | run: make lint 37 | 38 | - name: Test 39 | run: make coverage 40 | 41 | - name: Publish coverage to coveralls.io 42 | uses: shogo82148/actions-goveralls@v1 43 | if: ${{ matrix.os == 'ubuntu-20.04' }} 44 | with: 45 | path-to-profile: coverage.out 46 | 47 | - name: Build 48 | run: make build 49 | -------------------------------------------------------------------------------- /pkg/providers/s5/pgp.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | func (c *Client) getCipherEnginePGP(v *schemas.S5) (cipher.Engine, error) { 11 | var publicKeyPath, privateKeyPath string 12 | 13 | if v.CipherEnginePGP != nil && v.CipherEnginePGP.PublicKeyPath != nil { 14 | publicKeyPath = *v.CipherEnginePGP.PublicKeyPath 15 | } else if c.CipherEnginePGP != nil && c.CipherEnginePGP.PublicKeyPath != nil { 16 | publicKeyPath = *c.CipherEnginePGP.PublicKeyPath 17 | } else { 18 | publicKeyPath = os.Getenv("S5_PGP_PUBLIC_KEY_PATH") 19 | } 20 | 21 | if v.CipherEnginePGP != nil && v.CipherEnginePGP.PrivateKeyPath != nil { 22 | privateKeyPath = *v.CipherEnginePGP.PrivateKeyPath 23 | } else if c.CipherEnginePGP != nil && c.CipherEnginePGP.PrivateKeyPath != nil { 24 | privateKeyPath = *c.CipherEnginePGP.PrivateKeyPath 25 | } else { 26 | privateKeyPath = os.Getenv("S5_PGP_PRIVATE_KEY_PATH") 27 | } 28 | 29 | return cipher.NewPGPClient(publicKeyPath, privateKeyPath) 30 | } 31 | -------------------------------------------------------------------------------- /internal/cmd/render.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | // Render handles the processing of the variables and update of their values 11 | // on supported providers (tfc or local) 12 | func Render(ctx *cli.Context) (int, error) { 13 | c, cfg, err := configure(ctx) 14 | if err != nil { 15 | return 1, err 16 | } 17 | 18 | switch ctx.String("render-type") { 19 | case "tfc": 20 | w, err := c.ConfigureWorkspace(cfg, ctx.Bool("dry-run")) 21 | if err != nil { 22 | return 1, err 23 | } 24 | err = c.RenderVariablesOnTFC(cfg, w, ctx.Bool("dry-run"), ctx.Bool("ignore-ttls")) 25 | if err != nil { 26 | return 1, err 27 | } 28 | case "local": 29 | err = c.RenderVariablesLocally(cfg) 30 | if err != nil { 31 | return 1, err 32 | } 33 | case "disabled": 34 | log.Warningf("render-type set to disabled, not doing anything") 35 | return 0, nil 36 | default: 37 | return 1, fmt.Errorf("invalid render-type '%s'", ctx.String("render-type")) 38 | } 39 | 40 | return 0, nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/schemas/variable_test.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestVariableGetProviderEnv(t *testing.T) { 11 | v := &Variable{ 12 | Env: &Env{}, 13 | } 14 | 15 | p, err := v.GetProvider() 16 | assert.Equal(t, nil, err) 17 | assert.Equal(t, VariableProviderEnv, *p) 18 | } 19 | 20 | func TestVariableGetProviderS5(t *testing.T) { 21 | v := &Variable{ 22 | S5: &S5{}, 23 | } 24 | 25 | p, err := v.GetProvider() 26 | assert.Equal(t, nil, err) 27 | assert.Equal(t, VariableProviderS5, *p) 28 | } 29 | 30 | func TestVariableGetProviderVault(t *testing.T) { 31 | v := &Variable{ 32 | Vault: &Vault{}, 33 | } 34 | 35 | p, err := v.GetProvider() 36 | assert.Equal(t, nil, err) 37 | assert.Equal(t, VariableProviderVault, *p) 38 | } 39 | 40 | func TestVariableGetProviderInvalid(t *testing.T) { 41 | v := &Variable{ 42 | Name: "foo", 43 | } 44 | p, err := v.GetProvider() 45 | var emptyProvider *VariableProvider 46 | assert.Equal(t, fmt.Errorf("you can't have more or less than one provider configured per variable. Found 0 for 'foo'"), err) 47 | assert.Equal(t, emptyProvider, p) 48 | } 49 | -------------------------------------------------------------------------------- /docs/examples/provider_s5_aes.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a S5 payload ciphered with AES 2 | 3 | You can find more details on [how to use this cipher engine here](https://github.com/mvisonneau/s5/blob/main/examples/aes-gcm.md). 4 | 5 | We will consider here that the `S5_AES_KEY` environment variable has been configured accordingly. 6 | 7 | ```hcl 8 | tfc { 9 | organization = "acme" 10 | workspace { 11 | name = "foo" 12 | } 13 | } 14 | 15 | defaults { 16 | s5 { 17 | engine = "aes" 18 | } 19 | } 20 | 21 | tfvvar "my_variable"{ 22 | s5 { 23 | // Ciphered value 24 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 25 | } 26 | } 27 | ``` 28 | 29 | You can also override all the parameters on a per secret basis 30 | 31 | ```hcl 32 | envvar "my_other_variable"{ 33 | s5 { 34 | // In here you can optionally override all the default configuration 35 | engine = "aws" 36 | aws { 37 | kms-key-arn = "arn:aws:kms:*:111111111111:key/anotherkey" 38 | } 39 | // ... 40 | 41 | // Ciphered value 42 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 43 | } 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/examples/provider_s5_gcp_kms.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a S5 payload ciphered with GCP-KMS 2 | 3 | You can find more details on [how to use this cipher engine here](https://github.com/mvisonneau/s5/blob/main/examples/gcp-kms.md). 4 | 5 | We will consider here that the necessary accesses to the GCP KMS key have been configured accordingly. 6 | 7 | ```hcl 8 | tfc { 9 | organization = "acme" 10 | workspace { 11 | name = "foo" 12 | } 13 | } 14 | 15 | defaults { 16 | s5 { 17 | engine = "gcp" 18 | gcp { 19 | kms-key-name = "foo" 20 | } 21 | } 22 | } 23 | 24 | tfvvar "my_variable"{ 25 | s5 { 26 | // Ciphered value 27 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 28 | } 29 | } 30 | ``` 31 | 32 | You can also override all the parameters on a per secret basis 33 | 34 | ```hcl 35 | envvar "my_other_variable"{ 36 | s5 { 37 | // In here you can optionally override all the default configuration 38 | gcp { 39 | kms-key-name = "bar" 40 | } 41 | // ... 42 | 43 | // Ciphered value 44 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU2Y=}}" 45 | } 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/examples/provider_s5_vault.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a S5 payload ciphered with a Vault transit key 2 | 3 | You can find more details on [how to use this cipher engine here](https://github.com/mvisonneau/s5/blob/main/examples/vault.md). 4 | 5 | We will consider here that the necessary accesses for Vault and the transit key have been configured accordingly. 6 | 7 | ```hcl 8 | tfc { 9 | organization = "acme" 10 | workspace { 11 | name = "foo" 12 | } 13 | } 14 | 15 | defaults { 16 | s5 { 17 | engine = "vault" 18 | vault { 19 | transit-key = "foo" 20 | } 21 | } 22 | } 23 | 24 | tfvvar "my_variable"{ 25 | s5 { 26 | // Ciphered value 27 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 28 | } 29 | } 30 | ``` 31 | 32 | You can also override all the parameters on a per secret basis 33 | 34 | ```hcl 35 | envvar "my_other_variable"{ 36 | s5 { 37 | // In here you can optionally override all the default configuration 38 | vault { 39 | transit-key = "bar" 40 | } 41 | // ... 42 | 43 | // Ciphered value 44 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU2Y=}}" 45 | } 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/examples/provider_s5_pgp.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a S5 payload ciphered with a PGP keypair 2 | 3 | You can find more details on [how to use this cipher engine here](https://github.com/mvisonneau/s5/blob/main/examples/pgp.md). 4 | 5 | ```hcl 6 | tfc { 7 | organization = "acme" 8 | workspace { 9 | name = "foo" 10 | } 11 | } 12 | 13 | defaults { 14 | s5 { 15 | engine = "pgp" 16 | pgp { 17 | public-key-path = "~/public-key.pem" 18 | private-key-path = "~/private-key.pem" 19 | } 20 | } 21 | } 22 | 23 | tfvvar "my_variable"{ 24 | s5 { 25 | // Ciphered value 26 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 27 | } 28 | } 29 | ``` 30 | 31 | You can also override all the parameters on a per secret basis 32 | 33 | ```hcl 34 | envvar "my_other_variable"{ 35 | s5 { 36 | // In here you can optionally override all the default configuration 37 | pgp { 38 | public-key-path = "~/other-public-key.pem" 39 | private-key-path = "~/other-private-key.pem" 40 | } 41 | // ... 42 | 43 | // Ciphered value 44 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU2Y=}}" 45 | } 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/examples/provider_s5_aws_kms.md: -------------------------------------------------------------------------------- 1 | # Example of a variable configuration using a value stored in a S5 payload ciphered with AWS-KMS 2 | 3 | You can find more details on [how to use this cipher engine here](https://github.com/mvisonneau/s5/blob/main/examples/aws-kms.md). 4 | 5 | We will consider here that the necessary accesses to the AWS KMS key have been configured accordingly. 6 | 7 | ```hcl 8 | tfc { 9 | organization = "acme" 10 | workspace { 11 | name = "foo" 12 | } 13 | } 14 | 15 | defaults { 16 | s5 { 17 | engine = "aws" 18 | aws { 19 | kms-key-arn = "arn:aws:kms:*:111111111111:key/anotherkey" 20 | } 21 | } 22 | } 23 | 24 | tfvvar "my_variable"{ 25 | s5 { 26 | // Ciphered value 27 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU1Y=}}" 28 | } 29 | } 30 | ``` 31 | 32 | You can also override all the parameters on a per secret basis 33 | 34 | ```hcl 35 | envvar "my_other_variable"{ 36 | s5 { 37 | // In here you can optionally override all the default configuration 38 | aws { 39 | kms-key-arn = "arn:aws:kms:*:2222222222222:key/anothernicekey" 40 | } 41 | // ... 42 | 43 | // Ciphered value 44 | value = "{{s5:OGRmNTNmMzViZjA4Y2VkMjk5M2U3NDY4OTYwZWY4MzI3ZmU2Y=}}" 45 | } 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/examples/workspace_configuration.md: -------------------------------------------------------------------------------- 1 | # Example of a complete workspace configuration 2 | 3 | This example demonstrate how to fully configure a workspace using TFCW 4 | 5 | ```hcl 6 | tfc { 7 | // Name of your organization on TFC (required) 8 | organization = "acme" 9 | 10 | // Workspace related configuration block (required) 11 | workspace { 12 | // Name of the workspace of your Terraform stack on TFC (required) 13 | name = "foo" 14 | 15 | // Whether to run terraform remotely or locally (optional, default: true (remotely)) 16 | operations = false 17 | 18 | // Configure the workspace with the auto-apply flag (optional, default: ) 19 | auto-apply = true 20 | 21 | // Configure the workspace terraform version (optional, default: ) 22 | terraform-version = "0.12.24" 23 | 24 | // Configure the workspace working directory (optional, default: ) 25 | working-directory = "/foo" 26 | 27 | // Name of the SSH key to use (optional, default: ) 28 | ssh-key = "bar" 29 | } 30 | 31 | // This flag enables the creating of the workspace if TFCW cannot find it under 32 | // the organization (optional, default: true) 33 | workspace-auto-create = true 34 | 35 | // Whether to purge or leave the workspace variables which are 36 | // not configured within this file (optional, default: false) 37 | purge-unmanaged-variables = false 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/examples/provider_vault_multi_keys.md: -------------------------------------------------------------------------------- 1 | # Example of multiple variable configuration using a single Vault secret 2 | 3 | In this usecase, we will provision [AWS STS credentials](https://docs.aws.amazon.com/STS/latest/APIReference/Welcome.html) onto TFC env variables using the [Vault AWS secret engine](https://www.vaultproject.io/docs/secrets/aws/index.html). 4 | 5 | We consider here that the [Vault token](https://learn.hashicorp.com/vault/getting-started/authentication) 6 | has been made available either through the `VAULT_TOKEN` environment variable or at `~/.vault-token` 7 | 8 | We also consider that the AWS secret engine has been configured properly and policies are allowing use from requesting the credentials. 9 | 10 | ```hcl 11 | tfc { 12 | organization = "acme" 13 | workspace { 14 | name = "foo" 15 | } 16 | } 17 | 18 | defaults { 19 | vault { 20 | address = "https://vault.acme.local" 21 | } 22 | } 23 | 24 | // Notice that in this context, the provided has no impact on the outcome 25 | // Therefore you can use anything you would like as this value, it doesn't matter. 26 | envvar "_" { 27 | vault { 28 | method = "write" 29 | path = "aws/sts/foo" 30 | 31 | keys = { 32 | access_key = "AWS_ACCESS_KEY_ID", 33 | secret_key = "AWS_SECRET_ACCESS_KEY", 34 | security_token = "AWS_SESSION_TOKEN", 35 | } 36 | 37 | params = { 38 | ttl = "15m" 39 | } 40 | } 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /internal/cmd/utils_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | func NewTestContext() (ctx *cli.Context, flags, globalFlags *flag.FlagSet) { 14 | app := cli.NewApp() 15 | app.Name = "tfcw" 16 | 17 | app.Metadata = map[string]interface{}{ 18 | "startTime": time.Now(), 19 | } 20 | 21 | globalFlags = flag.NewFlagSet("test", flag.ContinueOnError) 22 | globalCtx := cli.NewContext(app, globalFlags, nil) 23 | 24 | flags = flag.NewFlagSet("test", flag.ContinueOnError) 25 | ctx = cli.NewContext(app, flags, globalCtx) 26 | 27 | globalFlags.String("log-level", "fatal", "") 28 | globalFlags.String("log-format", "text", "") 29 | 30 | return 31 | } 32 | 33 | func TestExit(t *testing.T) { 34 | err := exit(20, fmt.Errorf("test")) 35 | assert.Equal(t, "", err.Error()) 36 | assert.Equal(t, 20, err.ExitCode()) 37 | } 38 | 39 | func TestComputeConfigFilePath(t *testing.T) { 40 | assert.Equal(t, "./tfcw.hcl", computeConfigFilePath(".", "/tfcw.hcl")) 41 | assert.Equal(t, "/foo/bar/tfcw.hcl", computeConfigFilePath(".", "/foo/bar/tfcw.hcl")) 42 | } 43 | 44 | func TestHTTPSPrefixedURL(t *testing.T) { 45 | assert.Equal(t, "https://app.terraform.io", returnHTTPSPrefixedURL("app.terraform.io")) 46 | assert.Equal(t, "http://app.terraform.io", returnHTTPSPrefixedURL("http://app.terraform.io")) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/terraform/terraform_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var emptyTerraformConfig = ` 12 | terraform { 13 | backend "remote" { 14 | organization = "foo" 15 | } 16 | } 17 | ` 18 | 19 | var completeTerraformConfig = ` 20 | terraform { 21 | backend "remote" { 22 | hostname = "app.terraform.io" 23 | organization = "foo" 24 | token = "bar" 25 | 26 | workspaces { 27 | name = "baz" 28 | } 29 | } 30 | } 31 | ` 32 | 33 | func TestGetRemoteBackendConfig(t *testing.T) { 34 | tmpDir, err := ioutil.TempDir("", "tfcw-test") 35 | assert.Nil(t, err) 36 | defer os.RemoveAll(tmpDir) 37 | 38 | tests := []struct { 39 | config string 40 | expected RemoteBackendConfig 41 | }{ 42 | { 43 | emptyTerraformConfig, 44 | RemoteBackendConfig{ 45 | Organization: "foo", 46 | }, 47 | }, 48 | { 49 | completeTerraformConfig, 50 | RemoteBackendConfig{ 51 | Hostname: "app.terraform.io", 52 | Organization: "foo", 53 | Token: "bar", 54 | Workspace: "baz", 55 | }, 56 | }, 57 | } 58 | 59 | for _, test := range tests { 60 | f, err := os.Create(tmpDir + "/terraform.tf") 61 | assert.Nil(t, err) 62 | f.WriteString(test.config) 63 | f.Close() 64 | 65 | rbc, err := GetRemoteBackendConfig(tmpDir) 66 | assert.Nil(t, err) 67 | assert.NotNil(t, rbc) 68 | assert.Equal(t, test.expected, *rbc) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/cli/flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | var runCreate = cli.FlagsByName{ 8 | &cli.BoolFlag{ 9 | Name: "auto-discard", 10 | Usage: "will automatically discard the run once planned", 11 | }, 12 | &cli.BoolFlag{ 13 | Name: "auto-approve", 14 | Usage: "automatically approve the run once planned", 15 | }, 16 | &cli.BoolFlag{ 17 | Name: "ignore-pending-runs", 18 | Usage: "it will create the run even if there is already one or more run(s) in the workspace queue", 19 | }, 20 | &cli.BoolFlag{ 21 | Name: "no-prompt", 22 | Usage: "will not prompt for approval once planned", 23 | }, 24 | &cli.StringFlag{ 25 | Name: "output,o", 26 | Usage: "file on which to write the run ID", 27 | }, 28 | &cli.DurationFlag{ 29 | Name: "start-timeout,t", 30 | Usage: "time to wait for the plan to start (set to 0 to disable, it is the default)", 31 | }, 32 | } 33 | 34 | var dryRun = &cli.BoolFlag{ 35 | Name: "dry-run", 36 | Usage: "simulate what TFCW would do onto the TFC API", 37 | } 38 | 39 | var currentRun = &cli.BoolFlag{ 40 | Name: "current", 41 | Usage: "perform the action against the current run", 42 | } 43 | 44 | var message = &cli.StringFlag{ 45 | Name: "message,m", 46 | Usage: "custom message for the action", 47 | Value: "from TFCW", 48 | } 49 | 50 | var ignoreTTLs = &cli.BoolFlag{ 51 | Name: "ignore-ttls", 52 | Usage: "render all variables, unconditionnaly of their current expirations or configured TTLs", 53 | } 54 | 55 | var renderType = &cli.StringFlag{ 56 | Name: "render-type,r", 57 | Usage: "where to render to values - options are : tfc, local or disabled", 58 | Value: "tfc", 59 | } 60 | -------------------------------------------------------------------------------- /.github/prerelease.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | RELEASE_ID=$(curl -sL https://api.github.com/repos/${REPOSITORY}/releases/tags/edge | jq -r .id) 6 | HEAD_SHA=$(curl -sL https://api.github.com/repos/${REPOSITORY}/git/refs/heads/main | jq -r .object.sha) 7 | PRERELEASE_TAG=$(git describe --always --abbrev=7 --tags --exclude=edge) 8 | 9 | # Bump the edge tag to the head of main 10 | curl -sL \ 11 | -X PATCH \ 12 | -u "_:${GITHUB_TOKEN}" \ 13 | -H "Accept: application/vnd.github.v3+json" \ 14 | -d '{"sha":"'${HEAD_SHA}'","force":true}' \ 15 | "https://api.github.com/repos/${REPOSITORY}/git/refs/tags/edge" 16 | 17 | # Ensure we execute some cleanup functions on exit 18 | function cleanup { 19 | git tag -d ${PRERELEASE_TAG} || true 20 | git fetch --tags -f || true 21 | } 22 | trap cleanup EXIT 23 | 24 | # Build the binaries using a prerelease tag 25 | git tag -d edge 26 | git tag -f ${PRERELEASE_TAG} 27 | goreleaser release \ 28 | --rm-dist \ 29 | --skip-validate \ 30 | -f .goreleaser.pre.yml 31 | 32 | # Delete existing assets from the edge prerelease on GitHub 33 | for asset_url in $(curl -sL -H "Accept: application/vnd.github.v3+json" https://api.github.com/repos/${REPOSITORY}/releases/tags/edge | jq -r ".assets[].url"); do 34 | echo "deleting edge release asset: ${asset_url}" 35 | curl -sL \ 36 | -X DELETE \ 37 | -u "_:${GITHUB_TOKEN}" \ 38 | ${asset_url} 39 | done 40 | 41 | # Upload new assets onto the edge prerelease on GitHub 42 | for asset in $(find dist -type f -name "${NAME}_edge*"); do 43 | echo "uploading ${asset}.." 44 | curl -sL \ 45 | -u "_:${GITHUB_TOKEN}" \ 46 | -H "Accept: application/vnd.github.v3+json" \ 47 | -H "Content-Type: $(file -b --mime-type ${asset})" \ 48 | --data-binary @${asset} \ 49 | "https://uploads.github.com/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename $asset)" 50 | done 51 | 52 | # Upload snaps to the edge channel 53 | find dist -type f -name "*.snap" -exec snapcraft upload --release edge '{}' \; 54 | -------------------------------------------------------------------------------- /pkg/providers/s5/client.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mvisonneau/s5/pkg/cipher" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | ) 9 | 10 | // Client is here to support provider related functions 11 | type Client struct { 12 | CipherEngineType *schemas.S5CipherEngineType 13 | CipherEngineAES *schemas.S5CipherEngineAES 14 | CipherEngineAWS *schemas.S5CipherEngineAWS 15 | CipherEngineGCP *schemas.S5CipherEngineGCP 16 | CipherEnginePGP *schemas.S5CipherEnginePGP 17 | CipherEngineVault *schemas.S5CipherEngineVault 18 | } 19 | 20 | // GetValue returns a deciphered value from S5 21 | func (c *Client) GetValue(v *schemas.S5) (string, error) { 22 | variableCipher, err := c.getCipherEngine(v) 23 | if err != nil { 24 | return "", fmt.Errorf("s5 error whilst getting cipher engine: %s", err.Error()) 25 | } 26 | 27 | parsedValue, err := cipher.ParseInput(*v.Value) 28 | if err != nil { 29 | return "", fmt.Errorf("s5 error whilst parsing input: %s", err.Error()) 30 | } 31 | 32 | value, err := variableCipher.Decipher(parsedValue) 33 | if err != nil { 34 | return "", fmt.Errorf("s5 error whilst deciphering: %s", err.Error()) 35 | } 36 | 37 | return value, nil 38 | } 39 | 40 | func (c *Client) getCipherEngine(v *schemas.S5) (cipher.Engine, error) { 41 | var cipherEngineType *schemas.S5CipherEngineType 42 | if v.CipherEngineType != nil { 43 | cipherEngineType = v.CipherEngineType 44 | } else if c.CipherEngineType != nil { 45 | cipherEngineType = c.CipherEngineType 46 | } else { 47 | return nil, fmt.Errorf("you need to specify a S5 cipher engine") 48 | } 49 | 50 | switch *cipherEngineType { 51 | case schemas.S5CipherEngineTypeAES: 52 | return c.getCipherEngineAES(v) 53 | case schemas.S5CipherEngineTypeAWS: 54 | return c.getCipherEngineAWS(v) 55 | case schemas.S5CipherEngineTypeGCP: 56 | return c.getCipherEngineGCP(v) 57 | case schemas.S5CipherEngineTypePGP: 58 | return c.getCipherEnginePGP(v) 59 | case schemas.S5CipherEngineTypeVault: 60 | return c.getCipherEngineVault(v) 61 | default: 62 | return nil, fmt.Errorf("engine '%s' is not implemented yet", *cipherEngineType) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: release 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v[0-9]+.[0-9]+.[0-9]+' 8 | branches: 9 | - main 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | env: 16 | DOCKER_CLI_EXPERIMENTAL: 'enabled' 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v1 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v1 29 | 30 | - name: docker.io Login 31 | uses: docker/login-action@v1 32 | with: 33 | registry: docker.io 34 | username: ${{ github.repository_owner }} 35 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 36 | 37 | - name: ghcr.io login 38 | uses: docker/login-action@v1 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.repository_owner }} 42 | password: ${{ secrets.GH_PAT }} 43 | 44 | - name: quay.io Login 45 | uses: docker/login-action@v1 46 | with: 47 | registry: quay.io 48 | username: ${{ github.repository_owner }} 49 | password: ${{ secrets.QUAY_TOKEN }} 50 | 51 | - name: Snapcraft config 52 | uses: samuelmeuli/action-snapcraft@v1 53 | with: 54 | snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }} 55 | 56 | - name: Set up Go 57 | uses: actions/setup-go@v2 58 | with: 59 | go-version: 1.17 60 | 61 | - name: Import GPG key 62 | uses: crazy-max/ghaction-import-gpg@v3 63 | with: 64 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 65 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 66 | 67 | - name: Install goreleaser 68 | uses: goreleaser/goreleaser-action@v2 69 | with: 70 | version: v1.4.1 71 | install-only: true 72 | 73 | - name: Run goreleaser 74 | run: make ${{ github.ref == 'refs/heads/main' && 'pre' || '' }}release 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 77 | -------------------------------------------------------------------------------- /pkg/providers/s5/aes_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mvisonneau/s5/pkg/cipher" 8 | "github.com/mvisonneau/tfcw/pkg/schemas" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | testAESKey string = "cc6af4c2bf251c1cce0aebdbd39dc91d" 14 | ) 15 | 16 | func TestGetCipherEngineAES(t *testing.T) { 17 | cipherEngineType := schemas.S5CipherEngineTypeAES 18 | key := testAESKey 19 | 20 | // expected engine 21 | expectedEngine, err := cipher.NewAESClient(testAESKey) 22 | assert.Nil(t, err) 23 | 24 | // all defined in client, empty variable config (default settings) 25 | v := &schemas.S5{} 26 | c := &Client{ 27 | CipherEngineType: &cipherEngineType, 28 | CipherEngineAES: &schemas.S5CipherEngineAES{ 29 | Key: &key, 30 | }, 31 | } 32 | 33 | cipherEngine, err := c.getCipherEngine(v) 34 | assert.Nil(t, err) 35 | assert.Equal(t, expectedEngine, cipherEngine) 36 | 37 | // all defined in variable, empty client config 38 | c = &Client{} 39 | v = &schemas.S5{ 40 | CipherEngineType: &cipherEngineType, 41 | CipherEngineAES: &schemas.S5CipherEngineAES{ 42 | Key: &key, 43 | }, 44 | } 45 | 46 | cipherEngine, err = c.getCipherEngine(v) 47 | assert.Nil(t, err) 48 | assert.Equal(t, expectedEngine, cipherEngine) 49 | 50 | // key defined in environment variable 51 | os.Setenv("S5_AES_KEY", testAESKey) 52 | c = &Client{} 53 | v = &schemas.S5{ 54 | CipherEngineType: &cipherEngineType, 55 | } 56 | 57 | cipherEngine, err = c.getCipherEngine(v) 58 | assert.Nil(t, err) 59 | assert.Equal(t, expectedEngine, cipherEngine) 60 | 61 | // other engine & key defined in client, overridden in variable 62 | otherCipherEngineType := schemas.S5CipherEngineTypeVault 63 | otherKey := "4177252ea44dea6b9d66815ab5dda08b" 64 | 65 | c = &Client{ 66 | CipherEngineType: &otherCipherEngineType, 67 | CipherEngineAES: &schemas.S5CipherEngineAES{ 68 | Key: &otherKey, 69 | }, 70 | } 71 | v = &schemas.S5{ 72 | CipherEngineType: &cipherEngineType, 73 | CipherEngineAES: &schemas.S5CipherEngineAES{ 74 | Key: &key, 75 | }, 76 | } 77 | 78 | cipherEngine, err = c.getCipherEngine(v) 79 | assert.Nil(t, err) 80 | assert.Equal(t, expectedEngine, cipherEngine) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/schemas/s5.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | // S5 is a provider type 4 | type S5 struct { 5 | CipherEngineType *S5CipherEngineType `hcl:"engine"` 6 | CipherEngineAES *S5CipherEngineAES `hcl:"aes,block"` 7 | CipherEngineAWS *S5CipherEngineAWS `hcl:"aws,block"` 8 | CipherEngineGCP *S5CipherEngineGCP `hcl:"gcp,block"` 9 | CipherEnginePGP *S5CipherEnginePGP `hcl:"pgp,block"` 10 | CipherEngineVault *S5CipherEngineVault `hcl:"vault,block"` 11 | Value *string `hcl:"value"` 12 | } 13 | 14 | // S5CipherEngineType represents a S5 cipher engine type 15 | type S5CipherEngineType string 16 | 17 | const ( 18 | // S5CipherEngineTypeAES refers to an 'aes' s5 cipher engine type 19 | S5CipherEngineTypeAES S5CipherEngineType = "aes" 20 | 21 | // S5CipherEngineTypeAWS refers to an 'aws' s5 cipher engine type 22 | S5CipherEngineTypeAWS S5CipherEngineType = "aws" 23 | 24 | // S5CipherEngineTypeGCP refers to a 'gcp' s5 cipher engine type 25 | S5CipherEngineTypeGCP S5CipherEngineType = "gcp" 26 | 27 | // S5CipherEngineTypePGP refers to a 'pgp' s5 cipher engine type 28 | S5CipherEngineTypePGP S5CipherEngineType = "pgp" 29 | 30 | // S5CipherEngineTypeVault refers to a 'vault' s5 cipher engine type 31 | S5CipherEngineTypeVault S5CipherEngineType = "vault" 32 | ) 33 | 34 | // S5CipherEngineAES handles necessary configuration for an 'aes' s5 cipher engine 35 | type S5CipherEngineAES struct { 36 | Key *string `hcl:"key"` 37 | } 38 | 39 | // S5CipherEngineAWS handles necessary configuration for an 'aws' s5 cipher engine 40 | type S5CipherEngineAWS struct { 41 | KmsKeyArn *string `hcl:"kms-key-arn"` 42 | } 43 | 44 | // S5CipherEngineGCP handles necessary configuration for a 'gcp' s5 cipher engine 45 | type S5CipherEngineGCP struct { 46 | KmsKeyName *string `hcl:"kms-key-name"` 47 | } 48 | 49 | // S5CipherEnginePGP handles necessary configuration for a 'pgp' s5 cipher engine 50 | type S5CipherEnginePGP struct { 51 | PublicKeyPath *string `hcl:"public-key-path"` 52 | PrivateKeyPath *string `hcl:"private-key-path"` 53 | } 54 | 55 | // S5CipherEngineVault handles necessary configuration for a 'vault' s5 cipher engine 56 | type S5CipherEngineVault struct { 57 | TransitKey *string `hcl:"transit-key"` 58 | } 59 | -------------------------------------------------------------------------------- /internal/cmd/run_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var wd, _ = os.Getwd() 13 | 14 | const ( 15 | validConfig = ` 16 | tfc { 17 | organization = "foo" 18 | 19 | workspace { 20 | name = "bar" 21 | } 22 | } 23 | ` 24 | ) 25 | 26 | func createTestConfigFile(config string) (string, string, error) { 27 | tmpDir := os.TempDir() 28 | tmpFile, err := ioutil.TempFile(tmpDir, "tfcw-test-cfg-") 29 | if err != nil { 30 | return "", "", err 31 | } 32 | 33 | if _, err = tmpFile.Write([]byte(config)); err != nil { 34 | return "", "", fmt.Errorf("Failed to write to temporary file : %s", err.Error()) 35 | } 36 | 37 | if err = tmpFile.Close(); err != nil { 38 | return "", "", fmt.Errorf("Failed to close temporary file : %s", err.Error()) 39 | } 40 | 41 | configPath := fmt.Sprint(tmpFile.Name(), ".hcl") 42 | if err = os.Rename(tmpFile.Name(), fmt.Sprint(tmpFile.Name(), ".hcl")); err != nil { 43 | return "", "", fmt.Errorf("Failed to rename temporary file with file extension : %s", err.Error()) 44 | } 45 | 46 | return tmpDir, configPath, nil 47 | } 48 | 49 | func TestRenderWithDefaultValues(t *testing.T) { 50 | ctx, _, _ := NewTestContext() 51 | exitCode, err := Render(ctx) 52 | assert.Equal(t, "tfcw config/hcl: : Configuration file not found; The configuration file does not exist.", err.Error()) 53 | assert.Equal(t, 1, exitCode) 54 | } 55 | 56 | func TestRenderLocalWithValidConfig(t *testing.T) { 57 | tmpDir, tmpFilePath, err := createTestConfigFile(validConfig) 58 | if err != nil { 59 | t.Fatalf(fmt.Sprintf("error whilst creating temporary config file : %s", err.Error())) 60 | } 61 | defer os.Remove(tmpFilePath) 62 | 63 | ctx, flags, globalFlags := NewTestContext() 64 | flags.String("render-type", "local", "") 65 | globalFlags.String("working-dir", tmpDir, "") 66 | globalFlags.String("config-file", tmpFilePath, "") 67 | 68 | defer os.Remove(fmt.Sprint(wd, "/tfcw.auto.tfvars")) 69 | defer os.Remove(fmt.Sprint(wd, "/tfcw.env")) 70 | exitCode, err := Render(ctx) 71 | assert.Equal(t, nil, err) 72 | assert.Equal(t, 0, exitCode) 73 | } 74 | 75 | func TestRunCreateWithDefaultValues(t *testing.T) { 76 | ctx, _, _ := NewTestContext() 77 | exitCode, err := RunCreate(ctx) 78 | assert.Equal(t, "tfcw config/hcl: : Configuration file not found; The configuration file does not exist.", err.Error()) 79 | assert.Equal(t, 1, exitCode) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/providers/s5/aws_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mvisonneau/s5/pkg/cipher" 8 | cipherAWS "github.com/mvisonneau/s5/pkg/cipher/aws" 9 | "github.com/mvisonneau/tfcw/pkg/schemas" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | const ( 14 | testAWSKMSKeyArn string = "arn:aws:kms:*:111111111111:key/mykey" 15 | ) 16 | 17 | func TestGetCipherEngineAWS(t *testing.T) { 18 | cipherEngineType := schemas.S5CipherEngineTypeAWS 19 | kmsKeyArn := testAWSKMSKeyArn 20 | 21 | // expected engine 22 | expectedEngine, err := cipher.NewAWSClient(kmsKeyArn) 23 | assert.Nil(t, err) 24 | 25 | // all defined in client, empty variable config (default settings) 26 | v := &schemas.S5{} 27 | c := &Client{ 28 | CipherEngineType: &cipherEngineType, 29 | CipherEngineAWS: &schemas.S5CipherEngineAWS{ 30 | KmsKeyArn: &kmsKeyArn, 31 | }, 32 | } 33 | 34 | cipherEngine, err := c.getCipherEngine(v) 35 | assert.Nil(t, err) 36 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherAWS.Client).Config) 37 | 38 | // all defined in variable, empty client config 39 | c = &Client{} 40 | v = &schemas.S5{ 41 | CipherEngineType: &cipherEngineType, 42 | CipherEngineAWS: &schemas.S5CipherEngineAWS{ 43 | KmsKeyArn: &kmsKeyArn, 44 | }, 45 | } 46 | 47 | cipherEngine, err = c.getCipherEngine(v) 48 | assert.Nil(t, err) 49 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherAWS.Client).Config) 50 | 51 | // key defined in environment variable 52 | os.Setenv("S5_AWS_KMS_KEY_ARN", testAWSKMSKeyArn) 53 | c = &Client{} 54 | v = &schemas.S5{ 55 | CipherEngineType: &cipherEngineType, 56 | } 57 | 58 | cipherEngine, err = c.getCipherEngine(v) 59 | assert.Nil(t, err) 60 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherAWS.Client).Config) 61 | 62 | // other engine & key defined in client, overridden in variable 63 | otherCipherEngineType := schemas.S5CipherEngineTypeVault 64 | otherKmsKeyArn := "arn:aws:kms:*:111111111111:key/myotherkey" 65 | 66 | c = &Client{ 67 | CipherEngineType: &otherCipherEngineType, 68 | CipherEngineAWS: &schemas.S5CipherEngineAWS{ 69 | KmsKeyArn: &otherKmsKeyArn, 70 | }, 71 | } 72 | v = &schemas.S5{ 73 | CipherEngineType: &cipherEngineType, 74 | CipherEngineAWS: &schemas.S5CipherEngineAWS{ 75 | KmsKeyArn: &kmsKeyArn, 76 | }, 77 | } 78 | 79 | cipherEngine, err = c.getCipherEngine(v) 80 | assert.Nil(t, err) 81 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherAWS.Client).Config) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/providers/s5/vault_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/mvisonneau/s5/pkg/cipher" 8 | cipherVault "github.com/mvisonneau/s5/pkg/cipher/vault" 9 | "github.com/mvisonneau/tfcw/pkg/schemas" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | const ( 14 | testVaultTransitKey string = "foo" 15 | ) 16 | 17 | func TestGetCipherEngineVault(t *testing.T) { 18 | cipherEngineType := schemas.S5CipherEngineTypeVault 19 | key := testVaultTransitKey 20 | 21 | os.Setenv("VAULT_ADDR", "http://foo") 22 | os.Setenv("VAULT_TOKEN", "bar") 23 | 24 | // expected engine 25 | expectedEngine, err := cipher.NewVaultClient(key) 26 | assert.Nil(t, err) 27 | 28 | // all defined in client, empty variable config (default settings) 29 | v := &schemas.S5{} 30 | c := &Client{ 31 | CipherEngineType: &cipherEngineType, 32 | CipherEngineVault: &schemas.S5CipherEngineVault{ 33 | TransitKey: &key, 34 | }, 35 | } 36 | 37 | cipherEngine, err := c.getCipherEngine(v) 38 | assert.Nil(t, err) 39 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherVault.Client).Config) 40 | 41 | // all defined in variable, empty client config 42 | c = &Client{} 43 | v = &schemas.S5{ 44 | CipherEngineType: &cipherEngineType, 45 | CipherEngineVault: &schemas.S5CipherEngineVault{ 46 | TransitKey: &key, 47 | }, 48 | } 49 | 50 | cipherEngine, err = c.getCipherEngine(v) 51 | assert.Nil(t, err) 52 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherVault.Client).Config) 53 | 54 | // key defined in environment variable 55 | os.Setenv("S5_VAULT_TRANSIT_KEY", testVaultTransitKey) 56 | c = &Client{} 57 | v = &schemas.S5{ 58 | CipherEngineType: &cipherEngineType, 59 | } 60 | 61 | cipherEngine, err = c.getCipherEngine(v) 62 | assert.Nil(t, err) 63 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherVault.Client).Config) 64 | 65 | // other engine & key defined in client, overridden in variable 66 | otherCipherEngineType := schemas.S5CipherEngineTypeAES 67 | otherTransitKey := "bar" 68 | 69 | c = &Client{ 70 | CipherEngineType: &otherCipherEngineType, 71 | CipherEngineVault: &schemas.S5CipherEngineVault{ 72 | TransitKey: &otherTransitKey, 73 | }, 74 | } 75 | v = &schemas.S5{ 76 | CipherEngineType: &cipherEngineType, 77 | CipherEngineVault: &schemas.S5CipherEngineVault{ 78 | TransitKey: &key, 79 | }, 80 | } 81 | 82 | cipherEngine, err = c.getCipherEngine(v) 83 | assert.Nil(t, err) 84 | assert.Equal(t, expectedEngine.Config, cipherEngine.(*cipherVault.Client).Config) 85 | } 86 | -------------------------------------------------------------------------------- /internal/cmd/workspace.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | // WorkspaceConfigure configures the workspace 10 | func WorkspaceConfigure(ctx *cli.Context) (int, error) { 11 | c, cfg, err := configure(ctx) 12 | if err != nil { 13 | return 1, err 14 | } 15 | 16 | if _, err = c.ConfigureWorkspace(cfg, ctx.Bool("dry-run")); err != nil { 17 | return 1, err 18 | } 19 | 20 | return 0, nil 21 | } 22 | 23 | // WorkspaceStatus return status of the workspace on TFC 24 | func WorkspaceStatus(ctx *cli.Context) (int, error) { 25 | c, cfg, err := configure(ctx) 26 | if err != nil { 27 | return 1, err 28 | } 29 | 30 | if err := c.GetWorkspaceStatus(cfg); err != nil { 31 | return 1, err 32 | } 33 | 34 | return 0, nil 35 | } 36 | 37 | // WorkspaceEnableOperations enable operations on the workspace 38 | func WorkspaceEnableOperations(ctx *cli.Context) (int, error) { 39 | c, cfg, err := configure(ctx) 40 | if err != nil { 41 | return 1, err 42 | } 43 | 44 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 45 | if err != nil { 46 | return 1, err 47 | } 48 | 49 | err = c.SetWorkspaceOperations(w, true) 50 | if err != nil { 51 | return 1, err 52 | } 53 | 54 | fmt.Printf("enabled operations on '%s/%s'\n", w.Organization.Name, w.Name) 55 | 56 | return 0, nil 57 | } 58 | 59 | // WorkspaceDisableOperations disable operations on the workspace 60 | func WorkspaceDisableOperations(ctx *cli.Context) (int, error) { 61 | c, cfg, err := configure(ctx) 62 | if err != nil { 63 | return 1, err 64 | } 65 | 66 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 67 | if err != nil { 68 | return 1, err 69 | } 70 | 71 | err = c.SetWorkspaceOperations(w, false) 72 | if err != nil { 73 | return 1, err 74 | } 75 | 76 | fmt.Printf("disabled operations on '%s/%s'\n", w.Organization.Name, w.Name) 77 | 78 | return 0, nil 79 | } 80 | 81 | // WorkspaceDeleteVariables removes managed or all variables on TFC 82 | func WorkspaceDeleteVariables(ctx *cli.Context) (int, error) { 83 | c, cfg, err := configure(ctx) 84 | if err != nil { 85 | return 1, err 86 | } 87 | 88 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 89 | if err != nil { 90 | return 1, err 91 | } 92 | 93 | if ctx.Bool("all") { 94 | if err = c.DeleteAllWorkspaceVariables(w); err != nil { 95 | return 1, err 96 | } 97 | } else { 98 | if err = c.DeleteWorkspaceVariables(w, cfg.GetVariables()); err != nil { 99 | return 1, err 100 | } 101 | } 102 | 103 | return 0, nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/providers/s5/gcp_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | // import ( 4 | // "os" 5 | // "testing" 6 | 7 | // "github.com/stretchr/testify/assert" 8 | // "github.com/mvisonneau/s5/cipher" 9 | // cipherGCP "github.com/mvisonneau/s5/cipher/gcp" 10 | // "github.com/mvisonneau/tfcw/lib/schemas" 11 | // ) 12 | 13 | const ( 14 | testGCPKMSKeyName string = "foo" 15 | ) 16 | 17 | // TODO: Find a solution to get mocked credentials on Drone CI: 18 | // https://cloud.drone.io/mvisonneau/tfcw/27/1/3 19 | 20 | // func TestGetCipherEngineGCP(t *testing.T) { 21 | // cipherEngineType := schemas.S5CipherEngineTypeGCP 22 | // kmsKeyName := testGCPKMSKeyName 23 | 24 | // // expected engine 25 | // expectedEngine, err := cipher.NewGCPClient(kmsKeyName) 26 | // assert.Equal(t, err, nil) 27 | 28 | // // all defined in client, empty variable config (default settings) 29 | // v := &schemas.S5{} 30 | // c := &Client{ 31 | // CipherEngineType: &cipherEngineType, 32 | // CipherEngineGCP: &schemas.S5CipherEngineGCP{ 33 | // KmsKeyName: &kmsKeyName, 34 | // }, 35 | // } 36 | 37 | // cipherEngine, err := c.getCipherEngine(v) 38 | // assert.Equal(t, err, nil) 39 | // assert.Equal(t, cipherEngine.(*cipherGCP.Client).Config, expectedEngine.Config) 40 | 41 | // // all defined in variable, empty client config 42 | // c = &Client{} 43 | // v = &schemas.S5{ 44 | // CipherEngineType: &cipherEngineType, 45 | // CipherEngineGCP: &schemas.S5CipherEngineGCP{ 46 | // KmsKeyName: &kmsKeyName, 47 | // }, 48 | // } 49 | 50 | // cipherEngine, err = c.getCipherEngine(v) 51 | // assert.Equal(t, err, nil) 52 | // assert.Equal(t, cipherEngine.(*cipherGCP.Client).Config, expectedEngine.Config) 53 | 54 | // // key defined in environment variable 55 | // os.Setenv("S5_GCP_KMS_KEY_NAME", testGCPKMSKeyName) 56 | // c = &Client{} 57 | // v = &schemas.S5{ 58 | // CipherEngineType: &cipherEngineType, 59 | // } 60 | 61 | // cipherEngine, err = c.getCipherEngine(v) 62 | // assert.Equal(t, err, nil) 63 | // assert.Equal(t, cipherEngine.(*cipherGCP.Client).Config, expectedEngine.Config) 64 | 65 | // // other engine & key defined in client, overridden in variable 66 | // otherCipherEngineType := schemas.S5CipherEngineTypeVault 67 | // otherKmsKeyName := "bar" 68 | 69 | // c = &Client{ 70 | // CipherEngineType: &otherCipherEngineType, 71 | // CipherEngineGCP: &schemas.S5CipherEngineGCP{ 72 | // KmsKeyName: &otherKmsKeyName, 73 | // }, 74 | // } 75 | // v = &schemas.S5{ 76 | // CipherEngineType: &cipherEngineType, 77 | // CipherEngineGCP: &schemas.S5CipherEngineGCP{ 78 | // KmsKeyName: &kmsKeyName, 79 | // }, 80 | // } 81 | 82 | // cipherEngine, err = c.getCipherEngine(v) 83 | // assert.Equal(t, err, nil) 84 | // assert.Equal(t, cipherEngine.(*cipherGCP.Client).Config, expectedEngine.Config) 85 | // } 86 | -------------------------------------------------------------------------------- /pkg/terraform/terraform.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/terraform/pkg/configs" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // RemoteBackendConfig represents partial set of values 12 | // for the remote backend configuration we can set in 13 | // a Terraform file. 14 | type RemoteBackendConfig struct { 15 | Hostname string 16 | Organization string 17 | Token string 18 | Workspace string 19 | } 20 | 21 | // GetRemoteBackendConfig attempts to return the remote backend configuration 22 | // from a Terraform file. 23 | func GetRemoteBackendConfig(workingDir string) (*RemoteBackendConfig, error) { 24 | p := configs.NewParser(nil) 25 | 26 | c, err := p.LoadConfigDir(workingDir) 27 | if err.HasErrors() { 28 | return nil, err 29 | } 30 | 31 | if c.Backend == nil { 32 | log.Debug("terraform remote backend not configured, skipping this evaluation..") 33 | return nil, nil 34 | } 35 | 36 | if c.Backend.Type != "remote" { 37 | return nil, fmt.Errorf("Terraform state backend has not been configured as 'remote'") 38 | } 39 | 40 | rbc := &RemoteBackendConfig{} 41 | 42 | remote, _, err := c.Backend.Config.PartialContent(remoteBackendSchema) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if _, ok := remote.Attributes["hostname"]; ok { 48 | hostnameVar, err := remote.Attributes["hostname"].Expr.Value(nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | rbc.Hostname = hostnameVar.AsString() 53 | } 54 | 55 | if _, ok := remote.Attributes["organization"]; ok { 56 | organizationVar, err := remote.Attributes["organization"].Expr.Value(nil) 57 | if err != nil { 58 | return nil, err 59 | } 60 | rbc.Organization = organizationVar.AsString() 61 | } 62 | 63 | if _, ok := remote.Attributes["token"]; ok { 64 | tokenVar, err := remote.Attributes["token"].Expr.Value(nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | rbc.Token = tokenVar.AsString() 69 | } 70 | 71 | for _, block := range remote.Blocks { 72 | w, _, err := block.Body.PartialContent(remoteBackendWorkspaceSchema) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if _, ok := w.Attributes["name"]; ok { 78 | workspaceVar, err := w.Attributes["name"].Expr.Value(nil) 79 | if err != nil { 80 | return nil, err 81 | } 82 | rbc.Workspace = workspaceVar.AsString() 83 | } 84 | } 85 | 86 | return rbc, nil 87 | } 88 | 89 | var remoteBackendSchema = &hcl.BodySchema{ 90 | Attributes: []hcl.AttributeSchema{ 91 | { 92 | Name: "organization", 93 | }, 94 | { 95 | Name: "hostname", 96 | }, 97 | { 98 | Name: "token", 99 | }, 100 | }, 101 | Blocks: []hcl.BlockHeaderSchema{ 102 | { 103 | Type: "workspaces", 104 | }, 105 | }, 106 | } 107 | 108 | var remoteBackendWorkspaceSchema = &hcl.BodySchema{ 109 | Attributes: []hcl.AttributeSchema{ 110 | { 111 | Name: "name", 112 | }, 113 | }, 114 | } 115 | -------------------------------------------------------------------------------- /pkg/providers/s5/client_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetValueValid(t *testing.T) { 12 | cipherEngineType := schemas.S5CipherEngineTypeAES 13 | cipheredValue := "{{s5:NmRhN2I1YTFhNGE4ZjUzNzI5ZTNlMjk4YzQ3NWQzMDRiYmRkYjA6OTAzN2E3OGQ0YTFmY2U0ZDRmZmExYmU2}}" 14 | key := testAESKey 15 | 16 | c := &Client{} 17 | v := &schemas.S5{ 18 | CipherEngineType: &cipherEngineType, 19 | CipherEngineAES: &schemas.S5CipherEngineAES{ 20 | Key: &key, 21 | }, 22 | Value: &cipheredValue, 23 | } 24 | 25 | value, err := c.GetValue(v) 26 | assert.Nil(t, err) 27 | assert.Equal(t, "foo", value) 28 | } 29 | 30 | func TestGetValueInvalidCipherEngine(t *testing.T) { 31 | cipherEngineType := schemas.S5CipherEngineType("foo") 32 | c := &Client{} 33 | v := &schemas.S5{ 34 | CipherEngineType: &cipherEngineType, 35 | } 36 | 37 | value, err := c.GetValue(v) 38 | assert.Equal(t, fmt.Errorf("s5 error whilst getting cipher engine: engine 'foo' is not implemented yet"), err) 39 | assert.Equal(t, value, "") 40 | } 41 | 42 | func TestGetValueInvalidInput(t *testing.T) { 43 | cipherEngineType := schemas.S5CipherEngineTypeAES 44 | invalidCipheredValue := "{{foo}}" 45 | key := testAESKey 46 | 47 | c := &Client{} 48 | v := &schemas.S5{ 49 | CipherEngineType: &cipherEngineType, 50 | CipherEngineAES: &schemas.S5CipherEngineAES{ 51 | Key: &key, 52 | }, 53 | Value: &invalidCipheredValue, 54 | } 55 | 56 | value, err := c.GetValue(v) 57 | assert.Equal(t, fmt.Errorf("s5 error whilst parsing input: Invalid string format, should be '{{s5:*}}'"), err) 58 | assert.Equal(t, value, "") 59 | } 60 | 61 | func TestGetValueInvalidDecipher(t *testing.T) { 62 | cipherEngineType := schemas.S5CipherEngineTypeAES 63 | invalidCipheredValue := "{{s5:foo}}" 64 | key := testAESKey 65 | 66 | c := &Client{} 67 | v := &schemas.S5{ 68 | CipherEngineType: &cipherEngineType, 69 | CipherEngineAES: &schemas.S5CipherEngineAES{ 70 | Key: &key, 71 | }, 72 | Value: &invalidCipheredValue, 73 | } 74 | 75 | value, err := c.GetValue(v) 76 | assert.Equal(t, fmt.Errorf("s5 error whilst deciphering: base64decode error : illegal base64 data at input byte 0 - value : foo"), err) 77 | assert.Equal(t, value, "") 78 | } 79 | 80 | func TestGetCipherEngineUndefined(t *testing.T) { 81 | c := &Client{} 82 | v := &schemas.S5{} 83 | 84 | cipherEngine, err := c.getCipherEngine(v) 85 | assert.Equal(t, fmt.Errorf("you need to specify a S5 cipher engine"), err) 86 | assert.Equal(t, cipherEngine, nil) 87 | } 88 | 89 | func TestGetCipherEngineInvalid(t *testing.T) { 90 | cipherEngineType := schemas.S5CipherEngineType("foo") 91 | c := &Client{} 92 | v := &schemas.S5{ 93 | CipherEngineType: &cipherEngineType, 94 | } 95 | 96 | cipherEngine, err := c.getCipherEngine(v) 97 | assert.Equal(t, fmt.Errorf("engine 'foo' is not implemented yet"), err) 98 | assert.Nil(t, cipherEngine) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/providers/vault/vault.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/hashicorp/vault/api" 10 | "github.com/mitchellh/go-homedir" 11 | "github.com/mvisonneau/tfcw/pkg/schemas" 12 | ) 13 | 14 | // Client is here to support provider related functions 15 | type Client struct { 16 | *api.Client 17 | } 18 | 19 | // GetClient : Get a Vault client using Vault official params 20 | func GetClient(address, token string) (*Client, error) { 21 | c, err := api.NewClient(nil) 22 | if err != nil { 23 | return nil, fmt.Errorf("Error creating Vault client: %s", err.Error()) 24 | } 25 | 26 | if len(address) > 0 { 27 | if err = c.SetAddress(address); err != nil { 28 | return nil, err 29 | } 30 | } else if len(os.Getenv("VAULT_ADDR")) > 0 { 31 | if err = c.SetAddress(os.Getenv("VAULT_ADDR")); err != nil { 32 | return nil, err 33 | } 34 | } else { 35 | return nil, fmt.Errorf("Vault address is not defined") 36 | } 37 | 38 | if len(token) > 0 { 39 | c.SetToken(token) 40 | } else { 41 | token := os.Getenv("VAULT_TOKEN") 42 | if len(token) == 0 { 43 | home, _ := homedir.Dir() 44 | vaultTokenPath := filepath.Join(home, "/.vault-token") 45 | f, err := ioutil.ReadFile(filepath.Clean(vaultTokenPath)) 46 | if err != nil { 47 | return nil, fmt.Errorf("Vault token is not defined (VAULT_TOKEN or ~/.vault-token)") 48 | } 49 | 50 | token = string(f) 51 | } 52 | 53 | c.SetToken(token) 54 | } 55 | 56 | return &Client{c}, nil 57 | } 58 | 59 | // GetValues returns values from Vault 60 | func (c *Client) GetValues(v *schemas.Vault) (results map[string]string, err error) { 61 | results = make(map[string]string) 62 | if v != nil && v.Path != nil { 63 | var secret *api.Secret 64 | 65 | if v.Method == nil { 66 | m := "read" 67 | v.Method = &m 68 | } 69 | 70 | switch *v.Method { 71 | case "read": 72 | secret, err = c.Logical().Read(*v.Path) 73 | case "write": 74 | params := map[string]interface{}{} 75 | if v.Params != nil { 76 | for k, v := range *v.Params { 77 | params[k] = v 78 | } 79 | } 80 | secret, err = c.Logical().Write(*v.Path, params) 81 | default: 82 | return results, fmt.Errorf("unsupported method '%s'", *v.Method) 83 | } 84 | 85 | if err != nil { 86 | return results, fmt.Errorf("vault error : %s", err) 87 | } 88 | 89 | if secret == nil || len(secret.Data) == 0 { 90 | return results, fmt.Errorf("no results/keys returned for secret : %s", *v.Path) 91 | } 92 | 93 | // kv-v2 backend returns a slightly different response than others 94 | _, hasDataField := secret.Data["data"] 95 | _, hasMetaDataField := secret.Data["metadata"] 96 | if hasDataField && hasMetaDataField { 97 | for k, v := range secret.Data["data"].(map[string]interface{}) { 98 | results[k] = v.(string) 99 | } 100 | } else { 101 | for k, v := range secret.Data { 102 | results[k] = v.(string) 103 | } 104 | } 105 | 106 | return 107 | } 108 | 109 | return results, fmt.Errorf("no path defined for retrieving vault secret") 110 | } 111 | -------------------------------------------------------------------------------- /pkg/schemas/variable.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // VariableKind represents the kind of variable we want to 9 | // provision 10 | type VariableKind string 11 | 12 | const ( 13 | // VariableKindTerraform refers to a 'terraform' variable in TFC 14 | VariableKindTerraform VariableKind = "terraform" 15 | 16 | // VariableKindEnvironment refers to an 'environment' variable in TFC 17 | VariableKindEnvironment VariableKind = "environment" 18 | ) 19 | 20 | // VariableProvider represent the provider which can be used in order 21 | // to process the variable 22 | type VariableProvider string 23 | 24 | const ( 25 | // VariableProviderEnv refers to the 'env' variable provider 26 | VariableProviderEnv VariableProvider = "env" 27 | 28 | // VariableProviderS5 refers to the 's5' variable provider 29 | VariableProviderS5 VariableProvider = "s5" 30 | 31 | // VariableProviderVault refers to the 'vault' variable provider 32 | VariableProviderVault VariableProvider = "vault" 33 | ) 34 | 35 | // Variable is a generic handler of variable characteristics 36 | type Variable struct { 37 | Name string `hcl:"name,label"` 38 | Vault *Vault `hcl:"vault,block"` 39 | S5 *S5 `hcl:"s5,block"` 40 | Env *Env `hcl:"env,block"` 41 | Sensitive *bool `hcl:"sensitive"` 42 | HCL *bool `hcl:"hcl"` 43 | TTL *string `hcl:"ttl"` 44 | 45 | Kind VariableKind 46 | Value string 47 | } 48 | 49 | // VariableWithValue is a generic handler for found variable values 50 | type VariableWithValue struct { 51 | Variable 52 | Value string 53 | } 54 | 55 | // Variables is a slice of *Variable 56 | type Variables []*Variable 57 | 58 | // VariablesWithValues is a slice of *ComputedVariable 59 | type VariablesWithValues []*VariableWithValue 60 | 61 | // VariableExpirations holds the expiration times of variables, stored within TFC as 62 | // a jsonmap within an __TFCW_VARIABLES_EXPIRATIONS environment variable (quite hacky..) 63 | type VariableExpirations map[VariableKind]map[string]*VariableExpiration 64 | 65 | // VariableExpiration contains the Time To Live (TTL) and when the value of a variable 66 | // to expire is going to expire 67 | type VariableExpiration struct { 68 | TTL time.Duration `json:"ttl"` 69 | ExpireAt time.Time `json:"expire_at"` 70 | } 71 | 72 | // GetProvider returns the VariableProvider that can be used for processing the variable 73 | func (v *Variable) GetProvider() (*VariableProvider, error) { 74 | configuredProviders := 0 75 | var provider *VariableProvider 76 | 77 | if v.Env != nil { 78 | configuredProviders++ 79 | p := VariableProviderEnv 80 | provider = &p 81 | } 82 | 83 | if v.S5 != nil { 84 | configuredProviders++ 85 | p := VariableProviderS5 86 | provider = &p 87 | } 88 | 89 | if v.Vault != nil { 90 | configuredProviders++ 91 | p := VariableProviderVault 92 | provider = &p 93 | } 94 | 95 | if configuredProviders != 1 { 96 | return nil, fmt.Errorf("you can't have more or less than one provider configured per variable. Found %d for '%s'", configuredProviders, v.Name) 97 | } 98 | 99 | return provider, nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/tfcw/variables_test.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // func TestGetVaultValues(t *testing.T) { 13 | // ln, client := createTestVault(t) 14 | // defer ln.Close() 15 | // c := Client{ 16 | // Vault: &providerVault.Client{ 17 | // Client: client, 18 | // }, 19 | // } 20 | 21 | // path := "secret/foo" 22 | // key := "foo" 23 | // v := &schemas.Variable{ 24 | // Vault: &schemas.Vault{ 25 | // Path: &path, 26 | // Key: &key, 27 | // }, 28 | // } 29 | 30 | // value, err := c.getVaultValues(v) 31 | // assert.Equal(t, nil, err) 32 | // assert.Equal(t, "bar", value[0].Value) 33 | // } 34 | 35 | func TestIsVariableAlreadyProcessed(t *testing.T) { 36 | c := &Client{ 37 | ProcessedVariables: map[string]schemas.VariableKind{}, 38 | } 39 | 40 | v1 := "foo" 41 | assert.Equal(t, false, c.isVariableAlreadyProcessed(v1, schemas.VariableKindEnvironment)) 42 | assert.Equal(t, true, c.isVariableAlreadyProcessed(v1, schemas.VariableKindEnvironment)) 43 | assert.Equal(t, false, c.isVariableAlreadyProcessed(v1, schemas.VariableKindTerraform)) 44 | assert.Equal(t, true, c.isVariableAlreadyProcessed(v1, schemas.VariableKindTerraform)) 45 | } 46 | 47 | func TestLogVariableWithValue(t *testing.T) { 48 | // redirect logs to str variable 49 | var str bytes.Buffer 50 | log.SetOutput(&str) 51 | log.SetFormatter(&log.TextFormatter{DisableTimestamp: true}) 52 | 53 | v := &schemas.VariableWithValue{ 54 | Variable: schemas.Variable{ 55 | Name: "foo", 56 | Kind: schemas.VariableKindEnvironment, 57 | }, 58 | Value: "bar", 59 | } 60 | 61 | logVariableWithValue(v, true) 62 | assert.Equal(t, "level=info msg=\"[DRY-RUN] Set variable 'foo' (environment) : **********\"\n", str.String()) 63 | 64 | // no dry-mode 65 | str.Reset() 66 | logVariableWithValue(v, false) 67 | assert.Equal(t, "level=info msg=\"Set variable 'foo' (environment)\"\n", str.String()) 68 | } 69 | 70 | func TestSecureSensitiveString(t *testing.T) { 71 | assert.Equal(t, "**********", secureSensitiveString("foo")) 72 | assert.Equal(t, "l********e", secureSensitiveString("love")) 73 | } 74 | 75 | // func createTestVault(t *testing.T) (net.Listener, *api.Client) { 76 | // t.Helper() 77 | 78 | // // Create an in-memory, unsealed core (the "backend", if you will). 79 | // core, keyShares, rootToken := vault.TestCoreUnsealed(t) 80 | // _ = keyShares 81 | 82 | // // Start an HTTP server for the core. 83 | // ln, addr := http.TestServer(t, core) 84 | 85 | // // Create a client that talks to the server, initially authenticating with 86 | // // the root token. 87 | // conf := api.DefaultConfig() 88 | // conf.Address = addr 89 | 90 | // client, err := api.NewClient(conf) 91 | // assert.Nil(t, err) 92 | // client.SetToken(rootToken) 93 | 94 | // // Setup required secrets, policies, etc. 95 | // _, err = client.Logical().Write("secret/foo", map[string]interface{}{ 96 | // "foo": "bar", 97 | // "baz": "baz", 98 | // }) 99 | // assert.Nil(t, err) 100 | 101 | // return ln, client 102 | // } 103 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := tfcw 2 | FILES := $(shell git ls-files */*.go) 3 | REPOSITORY := mvisonneau/$(NAME) 4 | .DEFAULT_GOAL := help 5 | 6 | .PHONY: setup 7 | setup: ## Install required libraries/tools for build tasks 8 | @command -v gofumpt 2>&1 >/dev/null || go install mvdan.cc/gofumpt@v0.2.1 9 | @command -v gosec 2>&1 >/dev/null || go install github.com/securego/gosec/v2/cmd/gosec@v2.9.6 10 | @command -v ineffassign 2>&1 >/dev/null || go install github.com/gordonklaus/ineffassign@v0.0.0-20210914165742-4cc7213b9bc8 11 | @command -v misspell 2>&1 >/dev/null || go install github.com/client9/misspell/cmd/misspell@v0.3.4 12 | @command -v revive 2>&1 >/dev/null || go install github.com/mgechev/revive@v1.1.3 13 | 14 | .PHONY: fmt 15 | fmt: setup ## Format source code 16 | gofumpt -w $(FILES) 17 | 18 | .PHONY: lint 19 | lint: revive vet gofumpt ineffassign misspell gosec ## Run all lint related tests against the codebase 20 | 21 | .PHONY: revive 22 | revive: setup ## Test code syntax with revive 23 | revive -config .revive.toml $(FILES) 24 | 25 | .PHONY: vet 26 | vet: ## Test code syntax with go vet 27 | go vet ./... 28 | 29 | .PHONY: gofumpt 30 | gofumpt: setup ## Test code syntax with gofumpt 31 | gofumpt -d $(FILES) > gofumpt.out 32 | @if [ -s gofumpt.out ]; then cat gofumpt.out; rm gofumpt.out; exit 1; else rm gofumpt.out; fi 33 | 34 | .PHONY: ineffassign 35 | ineffassign: setup ## Test code syntax for ineffassign 36 | ineffassign ./... 37 | 38 | .PHONY: misspell 39 | misspell: setup ## Test code with misspell 40 | misspell -error $(FILES) 41 | 42 | .PHONY: gosec 43 | gosec: setup ## Test code for security vulnerabilities 44 | gosec -exclude=G307 ./... 45 | 46 | .PHONY: test 47 | test: ## Run the tests against the codebase 48 | go test -v -count=1 -race ./... 49 | 50 | .PHONY: install 51 | install: ## Build and install locally the binary (dev purpose) 52 | go install ./cmd/$(NAME) 53 | 54 | .PHONY: build 55 | build: ## Build the binaries using local GOOS 56 | go build ./cmd/$(NAME) 57 | 58 | .PHONY: release 59 | release: ## Build & release the binaries (stable) 60 | git tag -d edge 61 | goreleaser release --rm-dist 62 | find dist -type f -name "*.snap" -exec snapcraft upload --release stable,edge '{}' \; 63 | 64 | .PHONY: prerelease 65 | prerelease: setup ## Build & prerelease the binaries (edge) 66 | @\ 67 | REPOSITORY=$(REPOSITORY) \ 68 | NAME=$(NAME) \ 69 | GITHUB_TOKEN=$(GITHUB_TOKEN) \ 70 | .github/prerelease.sh 71 | 72 | .PHONY: clean 73 | clean: ## Remove binary if it exists 74 | rm -f $(NAME) 75 | 76 | .PHONY: coverage 77 | coverage: ## Generates coverage report 78 | rm -rf *.out 79 | go test -count=1 -race -v ./... -coverpkg=./... -coverprofile=coverage.out 80 | 81 | .PHONY: coverage-html 82 | coverage-html: ## Generates coverage report and displays it in the browser 83 | go tool cover -html=coverage.out 84 | 85 | .PHONY: is-git-dirty 86 | is-git-dirty: ## Tests if git is in a dirty state 87 | @git status --porcelain 88 | @test $(shell git status --porcelain | grep -c .) -eq 0 89 | 90 | .PHONY: all 91 | all: lint test build coverage ## Test, builds and ship package for all supported platforms 92 | 93 | .PHONY: help 94 | help: ## Displays this help 95 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 96 | -------------------------------------------------------------------------------- /pkg/providers/vault/vault_test.go: -------------------------------------------------------------------------------- 1 | // There seems to be a bug in a lib importer by hashicorp/vault/api that prevents the test from running 2 | // correctly on darwin.. 3 | // 4 | //go:build !darwin 5 | // +build !darwin 6 | 7 | package vault 8 | 9 | import ( 10 | "fmt" 11 | "os" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | var wd, _ = os.Getwd() 18 | 19 | func TestGetClient(t *testing.T) { 20 | os.Setenv("HOME", wd) 21 | os.Unsetenv("VAULT_ADDR") 22 | os.Unsetenv("VAULT_TOKEN") 23 | 24 | _, err := GetClient("", "") 25 | assert.Equal(t, fmt.Errorf("Vault address is not defined"), err) 26 | 27 | _, err = GetClient("foo", "") 28 | assert.Equal(t, fmt.Errorf("Vault token is not defined (VAULT_TOKEN or ~/.vault-token)"), err) 29 | 30 | _, err = GetClient("foo", "bar") 31 | assert.Nil(t, err) 32 | 33 | os.Setenv("VAULT_ADDR", "foo") 34 | os.Setenv("VAULT_TOKEN", "bar") 35 | _, err = GetClient("", "") 36 | assert.Nil(t, err) 37 | } 38 | 39 | // func TestGetValues(t *testing.T) { 40 | // ln, client := createTestVault(t) 41 | // defer ln.Close() 42 | // c := Client{client} 43 | 44 | // // Undefined path 45 | // v := &schemas.Vault{} 46 | // r, err := c.GetValues(v) 47 | // assert.Equal(t, fmt.Errorf("no path defined for retrieving vault secret"), err) 48 | // assert.Equal(t, map[string]string{}, r) 49 | 50 | // // Valid secret 51 | // validPath := "secret/foo" 52 | // v.Path = &validPath 53 | 54 | // r, err = c.GetValues(v) 55 | // assert.Nil(t, err) 56 | // assert.Equal(t, map[string]string{"secret": "bar"}, r) 57 | 58 | // // Unexistent secret 59 | // invalidPath := "secret/baz" 60 | // v.Path = &invalidPath 61 | 62 | // r, err = c.GetValues(v) 63 | // assert.Equal(t, fmt.Errorf("no results/keys returned for secret : secret/baz"), err) 64 | // assert.Equal(t, map[string]string{}, r) 65 | 66 | // // Invalid method 67 | // invalidMethod := "foo" 68 | // v.Method = &invalidMethod 69 | // r, err = c.GetValues(v) 70 | // assert.Equal(t, fmt.Errorf("unsupported method 'foo'"), err) 71 | // assert.Equal(t, map[string]string{}, r) 72 | 73 | // // Write method 74 | // writeMethod := "write" 75 | // params := map[string]string{"foo": "bar"} 76 | // v.Method = &writeMethod 77 | // v.Path = &validPath 78 | // v.Params = ¶ms 79 | 80 | // r, err = c.GetValues(v) 81 | // assert.Equal(t, fmt.Errorf("no results/keys returned for secret : secret/foo"), err) 82 | // assert.Equal(t, map[string]string{}, r) 83 | // } 84 | 85 | // func createTestVault(t *testing.T) (net.Listener, *api.Client) { 86 | // t.Helper() 87 | 88 | // // Create an in-memory, unsealed core (the "backend", if you will). 89 | // core, keyShares, rootToken := vault.TestCoreUnsealed(t) 90 | // _ = keyShares 91 | 92 | // // Start an HTTP server for the core. 93 | // ln, addr := http.TestServer(t, core) 94 | 95 | // // Create a client that talks to the server, initially authenticating with 96 | // // the root token. 97 | // conf := api.DefaultConfig() 98 | // conf.Address = addr 99 | 100 | // client, err := api.NewClient(conf) 101 | // assert.Nil(t, err) 102 | // client.SetToken(rootToken) 103 | 104 | // // Setup required secrets, policies, etc. 105 | // _, err = client.Logical().Write("secret/foo", map[string]interface{}{ 106 | // "secret": "bar", 107 | // }) 108 | // assert.Nil(t, err) 109 | 110 | // return ln, client 111 | // } 112 | -------------------------------------------------------------------------------- /internal/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/mvisonneau/tfcw/pkg/tfcw" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | // RunCreate create a run on TFC 12 | func RunCreate(ctx *cli.Context) (int, error) { 13 | c, cfg, err := configure(ctx) 14 | if err != nil { 15 | return 1, err 16 | } 17 | 18 | w, err := c.ConfigureWorkspace(cfg, false) 19 | if err != nil { 20 | return 1, err 21 | } 22 | 23 | if !ctx.Bool("ignore-pending-runs") { 24 | if runID, _ := c.GetWorkspaceCurrentRunID(w); runID != "" { 25 | return 1, fmt.Errorf("there is already a run (%s) pending on your workspace (%s), exiting", runID, w.ID) 26 | } 27 | } 28 | 29 | switch ctx.String("render-type") { 30 | case "tfc": 31 | err = c.RenderVariablesOnTFC(cfg, w, false, ctx.Bool("ignore-ttls")) 32 | if err != nil { 33 | return 1, err 34 | } 35 | case "local": 36 | err = c.RenderVariablesLocally(cfg) 37 | if err != nil { 38 | return 1, err 39 | } 40 | case "disabled": 41 | log.Infof("render-type set to disabled, not rendering values") 42 | return 0, nil 43 | default: 44 | return 1, fmt.Errorf("invalid render-type '%s'", ctx.String("render-type")) 45 | } 46 | 47 | if err = c.CreateRun(cfg, w, &tfcw.TFCCreateRunOptions{ 48 | AutoApprove: ctx.Bool("auto-approve"), 49 | AutoDiscard: ctx.Bool("auto-discard"), 50 | NoPrompt: ctx.Bool("no-prompt"), 51 | OutputPath: ctx.String("output"), 52 | Message: ctx.String("message"), 53 | StartTimeout: ctx.Duration("start-timeout"), 54 | }); err != nil { 55 | return 1, err 56 | } 57 | 58 | return 0, nil 59 | } 60 | 61 | // RunApprove approve a run on TFC 62 | func RunApprove(ctx *cli.Context) (int, error) { 63 | c, cfg, err := configure(ctx) 64 | if err != nil { 65 | return 1, err 66 | } 67 | 68 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 69 | if err != nil { 70 | return 1, err 71 | } 72 | 73 | runID := ctx.Args().Get(0) 74 | if ctx.Bool("current") { 75 | runID, err = c.GetWorkspaceCurrentRunID(w) 76 | if err != nil { 77 | return 1, err 78 | } 79 | } 80 | 81 | if err := c.ApproveRun(runID, ctx.String("message")); err != nil { 82 | return 1, err 83 | } 84 | 85 | return 0, nil 86 | } 87 | 88 | // RunDiscard discard a run on TFC 89 | func RunDiscard(ctx *cli.Context) (int, error) { 90 | c, cfg, err := configure(ctx) 91 | if err != nil { 92 | return 1, err 93 | } 94 | 95 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 96 | if err != nil { 97 | return 1, err 98 | } 99 | 100 | runID := ctx.Args().Get(0) 101 | if ctx.Bool("current") { 102 | runID, err = c.GetWorkspaceCurrentRunID(w) 103 | if err != nil { 104 | return 1, err 105 | } 106 | } 107 | 108 | if err := c.DiscardRun(runID, ctx.String("message")); err != nil { 109 | return 1, err 110 | } 111 | 112 | return 0, nil 113 | } 114 | 115 | // RunCurrentID return the ID of the current run on TFC 116 | func RunCurrentID(ctx *cli.Context) (int, error) { 117 | c, cfg, err := configure(ctx) 118 | if err != nil { 119 | return 1, err 120 | } 121 | 122 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 123 | if err != nil { 124 | return 1, err 125 | } 126 | 127 | runID, err := c.GetWorkspaceCurrentRunID(w) 128 | if err != nil { 129 | return 1, err 130 | } 131 | 132 | fmt.Println(runID) 133 | 134 | return 0, nil 135 | } 136 | -------------------------------------------------------------------------------- /pkg/tfcw/client_test.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "testing" 5 | 6 | providerEnv "github.com/mvisonneau/tfcw/pkg/providers/env" 7 | providerS5 "github.com/mvisonneau/tfcw/pkg/providers/s5" 8 | providerVault "github.com/mvisonneau/tfcw/pkg/providers/vault" 9 | "github.com/mvisonneau/tfcw/pkg/schemas" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func getTestConfig() (cfg *schemas.Config) { 14 | cfg = &schemas.Config{ 15 | Runtime: schemas.Runtime{}, 16 | } 17 | 18 | // We need to set the TFC token otherwise the client won't initiate correctly 19 | cfg.Runtime.TFC.Token = "_" 20 | 21 | return 22 | } 23 | 24 | func TestNewClient(t *testing.T) { 25 | c, err := NewClient(getTestConfig()) 26 | assert.Equal(t, nil, err) 27 | assert.IsType(t, &providerVault.Client{}, c.Vault) 28 | assert.IsType(t, &providerS5.Client{}, c.S5) 29 | assert.IsType(t, &providerEnv.Client{}, c.Env) 30 | } 31 | 32 | func TestIsVaultClientRequired(t *testing.T) { 33 | // Validate Vault client is not required if config is empty 34 | cfg := &schemas.Config{} 35 | 36 | assert.Equal(t, false, isVaultClientRequired(cfg)) 37 | 38 | // Validate Vault client is not required if config contains other variables with 39 | // different providers is empty 40 | s5CipherEngineType := schemas.S5CipherEngineTypeAES 41 | cfg.EnvironmentVariables = schemas.Variables{ 42 | &schemas.Variable{ 43 | S5: &schemas.S5{ 44 | CipherEngineType: &s5CipherEngineType, 45 | }, 46 | }, 47 | } 48 | assert.Equal(t, false, isVaultClientRequired(cfg)) 49 | 50 | path := "foo" 51 | cfg.EnvironmentVariables = schemas.Variables{ 52 | &schemas.Variable{ 53 | Vault: &schemas.Vault{ 54 | Path: &path, 55 | }, 56 | }, 57 | } 58 | assert.Equal(t, true, isVaultClientRequired(cfg)) 59 | } 60 | 61 | func TestGetVaultClient(t *testing.T) { 62 | fooString := "foo" 63 | cfg := &schemas.Config{ 64 | Defaults: &schemas.Defaults{ 65 | Vault: &schemas.Vault{ 66 | Address: &fooString, 67 | Token: &fooString, 68 | }, 69 | }, 70 | EnvironmentVariables: schemas.Variables{ 71 | &schemas.Variable{ 72 | Vault: &schemas.Vault{ 73 | Path: &fooString, 74 | }, 75 | }, 76 | }, 77 | } 78 | 79 | c, err := getVaultClient(cfg) 80 | assert.Equal(t, nil, err) 81 | assert.Equal(t, fooString, c.Address()) 82 | assert.Equal(t, fooString, c.Token()) 83 | } 84 | 85 | func TestGetS5Client(t *testing.T) { 86 | cipherEngineType := schemas.S5CipherEngineTypeAES 87 | cipherEngineAES := schemas.S5CipherEngineAES{} 88 | cipherEngineAWS := schemas.S5CipherEngineAWS{} 89 | cipherEngineGCP := schemas.S5CipherEngineGCP{} 90 | cipherEnginePGP := schemas.S5CipherEnginePGP{} 91 | cipherEngineVault := schemas.S5CipherEngineVault{} 92 | 93 | cfg := &schemas.Config{ 94 | Defaults: &schemas.Defaults{ 95 | S5: &schemas.S5{ 96 | CipherEngineType: &cipherEngineType, 97 | CipherEngineAES: &cipherEngineAES, 98 | CipherEngineAWS: &cipherEngineAWS, 99 | CipherEngineGCP: &cipherEngineGCP, 100 | CipherEnginePGP: &cipherEnginePGP, 101 | CipherEngineVault: &cipherEngineVault, 102 | }, 103 | }, 104 | } 105 | 106 | c := getS5Client(cfg) 107 | assert.Equal(t, cipherEngineType, *c.CipherEngineType) 108 | assert.Equal(t, cipherEngineAES, *c.CipherEngineAES) 109 | assert.Equal(t, cipherEngineAWS, *c.CipherEngineAWS) 110 | assert.Equal(t, cipherEngineGCP, *c.CipherEngineGCP) 111 | assert.Equal(t, cipherEnginePGP, *c.CipherEnginePGP) 112 | assert.Equal(t, cipherEngineVault, *c.CipherEngineVault) 113 | } 114 | -------------------------------------------------------------------------------- /.goreleaser.pre.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - main: ./cmd/tfcw 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - darwin 11 | - linux 12 | - windows 13 | goarch: 14 | - 386 15 | - amd64 16 | - arm64 17 | flags: 18 | - -trimpath 19 | ignore: 20 | - goos: darwin 21 | goarch: 386 22 | 23 | archives: 24 | - name_template: '{{ .ProjectName }}_edge_{{ .Os }}_{{ .Arch }}' 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | 29 | release: 30 | disable: true 31 | 32 | snapcrafts: 33 | - summary: Terraform Cloud Wrapper 34 | description: Manage Terraform Cloud configuration programatically. 35 | license: Apache-2.0 36 | confinement: strict 37 | grade: devel 38 | apps: 39 | tfcw: 40 | plugs: [home, network] 41 | 42 | dockers: 43 | - image_templates: 44 | - 'docker.io/mvisonneau/tfcw:latest-amd64' 45 | - 'ghcr.io/mvisonneau/tfcw:latest-amd64' 46 | - 'quay.io/mvisonneau/tfcw:latest-amd64' 47 | ids: [tfcw] 48 | goarch: amd64 49 | use: buildx 50 | build_flag_templates: 51 | - --platform=linux/amd64 52 | - --label=org.opencontainers.image.title={{ .ProjectName }} 53 | - --label=org.opencontainers.image.description={{ .ProjectName }} 54 | - --label=org.opencontainers.image.url=https://github.com/mvisonneau/tfcw 55 | - --label=org.opencontainers.image.source=https://github.com/mvisonneau/tfcw 56 | - --label=org.opencontainers.image.version={{ .Version }} 57 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 58 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 59 | - --label=org.opencontainers.image.licenses=Apache-2.0 60 | 61 | - image_templates: 62 | - 'docker.io/mvisonneau/tfcw:latest-arm64' 63 | - 'ghcr.io/mvisonneau/tfcw:latest-arm64' 64 | - 'quay.io/mvisonneau/tfcw:latest-arm64' 65 | ids: [tfcw] 66 | goarch: arm64 67 | use: buildx 68 | build_flag_templates: 69 | - --platform=linux/arm64 70 | - --label=org.opencontainers.image.title={{ .ProjectName }} 71 | - --label=org.opencontainers.image.description={{ .ProjectName }} 72 | - --label=org.opencontainers.image.url=https://github.com/mvisonneau/tfcw 73 | - --label=org.opencontainers.image.source=https://github.com/mvisonneau/tfcw 74 | - --label=org.opencontainers.image.version={{ .Version }} 75 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 76 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 77 | - --label=org.opencontainers.image.licenses=Apache-2.0 78 | 79 | docker_manifests: 80 | - name_template: docker.io/mvisonneau/tfcw:latest 81 | image_templates: 82 | - docker.io/mvisonneau/tfcw:latest-amd64 83 | - docker.io/mvisonneau/tfcw:latest-arm64 84 | 85 | - name_template: ghcr.io/mvisonneau/tfcw:latest 86 | image_templates: 87 | - ghcr.io/mvisonneau/tfcw:latest-amd64 88 | - ghcr.io/mvisonneau/tfcw:latest-arm64 89 | 90 | - name_template: quay.io/mvisonneau/tfcw:latest 91 | image_templates: 92 | - quay.io/mvisonneau/tfcw:latest-amd64 93 | - quay.io/mvisonneau/tfcw:latest-arm64 94 | 95 | signs: 96 | - artifacts: checksum 97 | args: 98 | [ 99 | '-u', 100 | 'C09CA9F71C5C988E65E3E5FCADEA38EDC46F25BE', 101 | '--output', 102 | '${signature}', 103 | '--detach-sign', 104 | '${artifact}', 105 | ] 106 | 107 | checksum: 108 | name_template: '{{ .ProjectName }}_edge_sha512sums.txt' 109 | algorithm: sha512 110 | 111 | changelog: 112 | skip: true 113 | -------------------------------------------------------------------------------- /pkg/tfcw/client.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | tfc "github.com/hashicorp/go-tfe" 10 | "github.com/jpillora/backoff" 11 | providerEnv "github.com/mvisonneau/tfcw/pkg/providers/env" 12 | providerS5 "github.com/mvisonneau/tfcw/pkg/providers/s5" 13 | providerVault "github.com/mvisonneau/tfcw/pkg/providers/vault" 14 | "github.com/mvisonneau/tfcw/pkg/schemas" 15 | ) 16 | 17 | // Client aggregates provider clients 18 | type Client struct { 19 | Vault *providerVault.Client 20 | S5 *providerS5.Client 21 | Env *providerEnv.Client 22 | TFC *tfc.Client 23 | Context context.Context 24 | ProcessedVariablesMutex sync.Mutex 25 | ProcessedVariables map[string]schemas.VariableKind 26 | Backoff *backoff.Backoff 27 | } 28 | 29 | // NewClient instantiate a Client from a provider Config 30 | func NewClient(cfg *schemas.Config) (c *Client, err error) { 31 | vaultClient, err := getVaultClient(cfg) 32 | if err != nil { 33 | return nil, fmt.Errorf("error getting vault client: %s", err) 34 | } 35 | 36 | tfcClient, err := getTFCClient(cfg) 37 | if err != nil { 38 | return nil, fmt.Errorf("error getting terraform cloud client: %s", err) 39 | } 40 | 41 | c = &Client{ 42 | Vault: vaultClient, 43 | S5: getS5Client(cfg), 44 | Env: &providerEnv.Client{}, 45 | TFC: tfcClient, 46 | Context: context.Background(), 47 | ProcessedVariables: map[string]schemas.VariableKind{}, 48 | Backoff: &backoff.Backoff{ 49 | Min: 1 * time.Second, 50 | Max: 20 * time.Second, 51 | Factor: 1.5, 52 | Jitter: false, 53 | }, 54 | } 55 | 56 | return 57 | } 58 | 59 | func getVaultClient(cfg *schemas.Config) (c *providerVault.Client, err error) { 60 | if isVaultClientRequired(cfg) { 61 | // Initializing Vault client with default values 62 | var vaultAddress, vaultToken string 63 | if cfg.Defaults != nil { 64 | if cfg.Defaults.Vault != nil { 65 | if cfg.Defaults.Vault.Address != nil { 66 | vaultAddress = *cfg.Defaults.Vault.Address 67 | } 68 | 69 | if cfg.Defaults.Vault.Token != nil { 70 | vaultToken = *cfg.Defaults.Vault.Token 71 | } 72 | } 73 | } 74 | 75 | c, err = providerVault.GetClient(vaultAddress, vaultToken) 76 | } 77 | return 78 | } 79 | 80 | func getS5Client(cfg *schemas.Config) (c *providerS5.Client) { 81 | c = &providerS5.Client{} 82 | if cfg.Defaults != nil && cfg.Defaults.S5 != nil { 83 | if cfg.Defaults.S5.CipherEngineType != nil { 84 | c.CipherEngineType = cfg.Defaults.S5.CipherEngineType 85 | } 86 | if cfg.Defaults.S5.CipherEngineAES != nil { 87 | c.CipherEngineAES = cfg.Defaults.S5.CipherEngineAES 88 | } 89 | if cfg.Defaults.S5.CipherEngineAWS != nil { 90 | c.CipherEngineAWS = cfg.Defaults.S5.CipherEngineAWS 91 | } 92 | if cfg.Defaults.S5.CipherEngineGCP != nil { 93 | c.CipherEngineGCP = cfg.Defaults.S5.CipherEngineGCP 94 | } 95 | if cfg.Defaults.S5.CipherEnginePGP != nil { 96 | c.CipherEnginePGP = cfg.Defaults.S5.CipherEnginePGP 97 | } 98 | if cfg.Defaults.S5.CipherEngineVault != nil { 99 | c.CipherEngineVault = cfg.Defaults.S5.CipherEngineVault 100 | } 101 | } 102 | return 103 | } 104 | 105 | func getTFCClient(cfg *schemas.Config) (c *tfc.Client, err error) { 106 | c, err = tfc.NewClient(&tfc.Config{ 107 | Address: cfg.Runtime.TFC.Address, 108 | Token: cfg.Runtime.TFC.Token, 109 | }) 110 | return 111 | } 112 | 113 | func isVaultClientRequired(cfg *schemas.Config) bool { 114 | for _, v := range append(cfg.TerraformVariables, cfg.EnvironmentVariables...) { 115 | if v.Vault != nil { 116 | return true 117 | } 118 | } 119 | return false 120 | } 121 | -------------------------------------------------------------------------------- /pkg/schemas/config.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "time" 5 | 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // Config handles all components that can be defined in 10 | // a tfcw config file 11 | type Config struct { 12 | TFC *TFC `hcl:"tfc,block"` 13 | Defaults *Defaults `hcl:"defaults,block"` 14 | TerraformVariables Variables `hcl:"tfvar,block"` 15 | EnvironmentVariables Variables `hcl:"envvar,block"` 16 | 17 | Runtime Runtime 18 | } 19 | 20 | // Runtime is a struct used by the client in order 21 | // to store values configured at runtime 22 | type Runtime struct { 23 | WorkingDir string 24 | TFC struct { 25 | Address string 26 | Token string 27 | Organization string 28 | Workspace string 29 | } 30 | } 31 | 32 | // GetVariables returns a Variables containing the configured variables 33 | func (cfg *Config) GetVariables() (variables Variables) { 34 | for _, variable := range cfg.TerraformVariables { 35 | variable.Kind = VariableKindTerraform 36 | variables = append(variables, variable) 37 | } 38 | 39 | for _, variable := range cfg.EnvironmentVariables { 40 | variable.Kind = VariableKindEnvironment 41 | variables = append(variables, variable) 42 | } 43 | 44 | return 45 | } 46 | 47 | // GetVariableTTL returns the TTL of a variable 48 | func (cfg *Config) GetVariableTTL(v *Variable) (ttl time.Duration, err error) { 49 | if v.TTL != nil { 50 | return time.ParseDuration(*v.TTL) 51 | } 52 | 53 | if cfg.Defaults != nil && cfg.Defaults.Variable != nil && cfg.Defaults.Variable.TTL != nil { 54 | return time.ParseDuration(*cfg.Defaults.Variable.TTL) 55 | } 56 | return time.Duration(0), nil 57 | } 58 | 59 | // ComputeNewVariableExpirations ... 60 | func (cfg *Config) ComputeNewVariableExpirations(updatedVariables Variables, existingVariableExpirations VariableExpirations) (variableExpirations VariableExpirations, hasChanges bool, err error) { 61 | if len(existingVariableExpirations) > 0 { 62 | variableExpirations = existingVariableExpirations 63 | } else { 64 | variableExpirations = make(VariableExpirations) 65 | } 66 | 67 | if len(updatedVariables) > 0 { 68 | hasChanges = true 69 | } 70 | 71 | for _, v := range updatedVariables { 72 | if _, ok := variableExpirations[v.Kind]; !ok { 73 | variableExpirations[v.Kind] = map[string]*VariableExpiration{} 74 | } 75 | 76 | var ttl time.Duration 77 | ttl, err = cfg.GetVariableTTL(v) 78 | if err != nil { 79 | return 80 | } 81 | 82 | // If there is no TTL defined, we omit this variable from the expirations list 83 | if ttl == 0 { 84 | if _, ok := variableExpirations[v.Kind][v.Name]; ok { 85 | delete(variableExpirations[v.Kind], v.Name) 86 | } 87 | continue 88 | } 89 | 90 | variableExpirations[v.Kind][v.Name] = &VariableExpiration{ 91 | TTL: ttl, 92 | ExpireAt: time.Now().Add(ttl), 93 | } 94 | } 95 | 96 | // Cleanup maps which could turn out to be empty 97 | for k := range variableExpirations { 98 | if len(variableExpirations[k]) == 0 { 99 | delete(variableExpirations, k) 100 | } 101 | } 102 | 103 | return 104 | } 105 | 106 | // GetVariablesToUpdate returns the list of the variables to update based on the current configuration 107 | // and the existing variables 108 | func (cfg *Config) GetVariablesToUpdate(variableExpirations VariableExpirations) (variables Variables, err error) { 109 | for _, v := range cfg.GetVariables() { 110 | var ttl time.Duration 111 | ttl, err = cfg.GetVariableTTL(v) 112 | if err != nil { 113 | return 114 | } 115 | 116 | if ttl > 0 { 117 | // Check if there is a TTL flag set for this variable 118 | if variableExpiration, ok := variableExpirations[v.Kind][v.Name]; ok { 119 | // If the TTL hasn't changed and the expiration date is still in the future we do not update it 120 | if ttl == variableExpiration.TTL && variableExpiration.ExpireAt.After(time.Now()) { 121 | log.Debugf("variable %s (%s) is still valid for %s (ttl: %s), not updating", v.Name, v.Kind, variableExpiration.ExpireAt.Sub(time.Now()).String(), variableExpiration.TTL.String()) 122 | continue 123 | } 124 | } 125 | } 126 | 127 | variables = append(variables, v) 128 | } 129 | return 130 | } 131 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | 5 | builds: 6 | - main: ./cmd/tfcw 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - darwin 11 | - linux 12 | - windows 13 | goarch: 14 | - 386 15 | - amd64 16 | - arm64 17 | flags: 18 | - -trimpath 19 | ignore: 20 | - goos: darwin 21 | goarch: 386 22 | 23 | archives: 24 | - name_template: '{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}' 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | 29 | nfpms: 30 | - maintainer: &author Maxime VISONNEAU 31 | description: &description Terraform Cloud Wrapper 32 | license: &license Apache-2.0 33 | homepage: &homepage https://github.com/mvisonneau/tfcw 34 | vendor: *author 35 | file_name_template: '{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}' 36 | formats: 37 | - deb 38 | - rpm 39 | 40 | brews: 41 | - description: *description 42 | homepage: *homepage 43 | folder: Formula 44 | tap: 45 | owner: mvisonneau 46 | name: homebrew-tap 47 | 48 | scoop: 49 | description: *description 50 | homepage: *homepage 51 | license: *license 52 | bucket: 53 | owner: mvisonneau 54 | name: scoops 55 | 56 | snapcrafts: 57 | - summary: *description 58 | description: Manage Terraform Cloud configuration programatically. 59 | license: *license 60 | grade: stable 61 | apps: 62 | tfcw: 63 | plugs: [home, network] 64 | 65 | dockers: 66 | - image_templates: 67 | - 'docker.io/mvisonneau/tfcw:{{ .Tag }}-amd64' 68 | - 'ghcr.io/mvisonneau/tfcw:{{ .Tag }}-amd64' 69 | - 'quay.io/mvisonneau/tfcw:{{ .Tag }}-amd64' 70 | ids: [tfcw] 71 | goarch: amd64 72 | use: buildx 73 | build_flag_templates: 74 | - --platform=linux/amd64 75 | - --label=org.opencontainers.image.title={{ .ProjectName }} 76 | - --label=org.opencontainers.image.description={{ .ProjectName }} 77 | - --label=org.opencontainers.image.url=https://github.com/mvisonneau/tfcw 78 | - --label=org.opencontainers.image.source=https://github.com/mvisonneau/tfcw 79 | - --label=org.opencontainers.image.version={{ .Version }} 80 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 81 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 82 | - --label=org.opencontainers.image.licenses=Apache-2.0 83 | 84 | - image_templates: 85 | - 'docker.io/mvisonneau/tfcw:{{ .Tag }}-arm64' 86 | - 'ghcr.io/mvisonneau/tfcw:{{ .Tag }}-arm64' 87 | - 'quay.io/mvisonneau/tfcw:{{ .Tag }}-arm64' 88 | ids: [tfcw] 89 | goarch: arm64 90 | use: buildx 91 | build_flag_templates: 92 | - --platform=linux/arm64 93 | - --label=org.opencontainers.image.title={{ .ProjectName }} 94 | - --label=org.opencontainers.image.description={{ .ProjectName }} 95 | - --label=org.opencontainers.image.url=https://github.com/mvisonneau/tfcw 96 | - --label=org.opencontainers.image.source=https://github.com/mvisonneau/tfcw 97 | - --label=org.opencontainers.image.version={{ .Version }} 98 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 99 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 100 | - --label=org.opencontainers.image.licenses=Apache-2.0 101 | 102 | docker_manifests: 103 | - name_template: docker.io/mvisonneau/tfcw:{{ .Tag }} 104 | image_templates: 105 | - docker.io/mvisonneau/tfcw:{{ .Tag }}-amd64 106 | - docker.io/mvisonneau/tfcw:{{ .Tag }}-arm64 107 | 108 | - name_template: ghcr.io/mvisonneau/tfcw:{{ .Tag }} 109 | image_templates: 110 | - ghcr.io/mvisonneau/tfcw:{{ .Tag }}-amd64 111 | - ghcr.io/mvisonneau/tfcw:{{ .Tag }}-arm64 112 | 113 | - name_template: quay.io/mvisonneau/tfcw:{{ .Tag }} 114 | image_templates: 115 | - quay.io/mvisonneau/tfcw:{{ .Tag }}-amd64 116 | - quay.io/mvisonneau/tfcw:{{ .Tag }}-arm64 117 | 118 | checksum: 119 | name_template: '{{ .ProjectName }}_{{ .Tag }}_sha512sums.txt' 120 | algorithm: sha512 121 | 122 | signs: 123 | - artifacts: checksum 124 | args: 125 | [ 126 | '-u', 127 | 'C09CA9F71C5C988E65E3E5FCADEA38EDC46F25BE', 128 | '--output', 129 | '${signature}', 130 | '--detach-sign', 131 | '${artifact}', 132 | ] 133 | 134 | changelog: 135 | skip: true 136 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/urfave/cli/v2" 9 | 10 | "github.com/mvisonneau/tfcw/internal/cmd" 11 | ) 12 | 13 | // Run handles the instanciation of the CLI application 14 | func Run(version string, args []string) { 15 | err := NewApp(version, time.Now()).Run(args) 16 | if err != nil { 17 | fmt.Println(err) 18 | os.Exit(1) 19 | } 20 | } 21 | 22 | // NewApp configures the CLI application 23 | func NewApp(version string, start time.Time) (app *cli.App) { 24 | app = cli.NewApp() 25 | app.Name = "tfcw" 26 | app.Version = version 27 | app.Usage = "Terraform Cloud Wrapper" 28 | app.EnableBashCompletion = true 29 | 30 | app.Flags = cli.FlagsByName{ 31 | &cli.StringFlag{ 32 | Name: "address", 33 | Aliases: []string{"a"}, 34 | EnvVars: []string{"TFCW_ADDRESS"}, 35 | Usage: "`address` to access Terraform Cloud API", 36 | }, 37 | &cli.StringFlag{ 38 | Name: "config-file", 39 | Aliases: []string{"c"}, 40 | EnvVars: []string{"TFCW_CONFIG_FILE"}, 41 | Usage: "`path` of a readable TFCW configuration file (.hcl or .json)", 42 | Value: "/tfcw.hcl", 43 | }, 44 | &cli.StringFlag{ 45 | Name: "log-level", 46 | EnvVars: []string{"TFCW_LOG_LEVEL"}, 47 | Usage: "log `level` (debug,info,warn,fatal,panic)", 48 | Value: "info", 49 | }, 50 | &cli.StringFlag{ 51 | Name: "log-format", 52 | EnvVars: []string{"TFCW_LOG_FORMAT"}, 53 | Usage: "log `format` (json,text)", 54 | Value: "text", 55 | }, 56 | &cli.StringFlag{ 57 | Name: "organization", 58 | Aliases: []string{"o"}, 59 | EnvVars: []string{"TFCW_ORGANIZATION"}, 60 | Usage: "`organization` to use on Terraform Cloud API", 61 | }, 62 | &cli.StringFlag{ 63 | Name: "token", 64 | Aliases: []string{"t"}, 65 | EnvVars: []string{"TFCW_TOKEN"}, 66 | Usage: "`token` to access Terraform Cloud API", 67 | }, 68 | &cli.StringFlag{ 69 | Name: "working-dir", 70 | Aliases: []string{"d"}, 71 | EnvVars: []string{"TFCW_WORKING_DIR"}, 72 | Usage: "`path` of the directory containing your Terraform files", 73 | Value: ".", 74 | }, 75 | &cli.StringFlag{ 76 | Name: "workspace", 77 | Aliases: []string{"w"}, 78 | EnvVars: []string{"TFCW_WORKSPACE"}, 79 | Usage: "`workspace` to use on Terraform Cloud API", 80 | }, 81 | } 82 | 83 | app.Commands = cli.CommandsByName{ 84 | { 85 | Name: "render", 86 | Usage: "render variables values", 87 | Action: cmd.ExecWrapper(cmd.Render), 88 | Flags: cli.FlagsByName{renderType, ignoreTTLs, dryRun}, 89 | }, 90 | { 91 | Name: "run", 92 | Usage: "manipulate runs", 93 | Subcommands: cli.Commands{ 94 | { 95 | Name: "approve", 96 | Usage: "approve a run given its 'ID'", 97 | Action: cmd.ExecWrapper(cmd.RunApprove), 98 | Flags: cli.FlagsByName{currentRun, message}, 99 | }, 100 | { 101 | Name: "create", 102 | Usage: "create a run on TFC", 103 | Action: cmd.ExecWrapper(cmd.RunCreate), 104 | Flags: append(runCreate, message, renderType, ignoreTTLs), 105 | }, 106 | { 107 | Name: "discard", 108 | Usage: "discard a run given its 'ID'", 109 | Action: cmd.ExecWrapper(cmd.RunDiscard), 110 | Flags: cli.FlagsByName{currentRun, message}, 111 | }, 112 | { 113 | Name: "current-id", 114 | Usage: "return the id of the current run", 115 | Action: cmd.ExecWrapper(cmd.RunCurrentID), 116 | }, 117 | }, 118 | }, 119 | { 120 | Name: "workspace", 121 | Aliases: []string{"ws"}, 122 | Usage: "manipulate the workspace", 123 | Subcommands: cli.Commands{ 124 | { 125 | Name: "configure", 126 | Aliases: []string{"cfg"}, 127 | Usage: "apply defined configuration to the workspace", 128 | Action: cmd.ExecWrapper(cmd.WorkspaceConfigure), 129 | Flags: cli.FlagsByName{dryRun}, 130 | }, 131 | { 132 | Name: "delete-variables", 133 | Usage: "remove configured workspace variables (default: scoped to variables defined in the config file)", 134 | Action: cmd.ExecWrapper(cmd.WorkspaceDeleteVariables), 135 | Flags: cli.FlagsByName{&cli.BoolFlag{ 136 | Name: "all, a", 137 | Usage: "delete all variables", 138 | }}, 139 | }, 140 | { 141 | Name: "status", 142 | Usage: "return the status of the workspace", 143 | Action: cmd.ExecWrapper(cmd.WorkspaceStatus), 144 | }, 145 | { 146 | Name: "operations", 147 | Usage: "manages the operations value of the workspace", 148 | Subcommands: cli.Commands{ 149 | { 150 | Name: "enable", 151 | Usage: "enable remote operations on the workspace", 152 | Action: cmd.ExecWrapper(cmd.WorkspaceEnableOperations), 153 | }, 154 | { 155 | Name: "disable", 156 | Usage: "disable remote operations on the workspace", 157 | Action: cmd.ExecWrapper(cmd.WorkspaceDisableOperations), 158 | }, 159 | }, 160 | }, 161 | }, 162 | }, 163 | } 164 | 165 | app.Metadata = map[string]interface{}{ 166 | "startTime": start, 167 | } 168 | 169 | return 170 | } 171 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mvisonneau/tfcw 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/hashicorp/go-tfe v0.25.0 7 | github.com/hashicorp/hcl/v2 v2.11.1 8 | github.com/hashicorp/terraform v1.1.5 9 | github.com/hashicorp/vault/api v1.3.1 10 | github.com/jpillora/backoff v1.0.0 11 | github.com/manifoldco/promptui v0.9.0 12 | github.com/mitchellh/go-homedir v1.1.0 13 | github.com/mvisonneau/go-helpers v0.0.1 14 | github.com/mvisonneau/s5 v0.1.12 15 | github.com/openlyinc/pointy v1.1.2 16 | github.com/sirupsen/logrus v1.8.1 17 | github.com/stretchr/testify v1.7.0 18 | github.com/urfave/cli/v2 v2.3.0 19 | github.com/zclconf/go-cty v1.10.0 20 | ) 21 | 22 | require ( 23 | cloud.google.com/go v0.100.2 // indirect 24 | cloud.google.com/go/compute v1.2.0 // indirect 25 | cloud.google.com/go/iam v0.1.1 // indirect 26 | cloud.google.com/go/kms v1.2.0 // indirect 27 | cloud.google.com/go/storage v1.20.0 // indirect 28 | github.com/agext/levenshtein v1.2.3 // indirect 29 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 30 | github.com/apparentlymart/go-versions v1.0.1 // indirect 31 | github.com/armon/go-metrics v0.3.10 // indirect 32 | github.com/armon/go-radix v1.0.0 // indirect 33 | github.com/aws/aws-sdk-go v1.42.51 // indirect 34 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 35 | github.com/cenkalti/backoff/v3 v3.2.2 // indirect 36 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 37 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect 38 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect 39 | github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect 40 | github.com/davecgh/go-spew v1.1.1 // indirect 41 | github.com/fatih/color v1.13.0 // indirect 42 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 43 | github.com/golang/protobuf v1.5.2 // indirect 44 | github.com/golang/snappy v0.0.4 // indirect 45 | github.com/google/go-cmp v0.5.7 // indirect 46 | github.com/google/go-querystring v1.1.0 // indirect 47 | github.com/googleapis/gax-go/v2 v2.1.1 // indirect 48 | github.com/hashicorp/errwrap v1.1.0 // indirect 49 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 50 | github.com/hashicorp/go-getter v1.5.11 // indirect 51 | github.com/hashicorp/go-hclog v1.1.0 // indirect 52 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 53 | github.com/hashicorp/go-multierror v1.1.1 // indirect 54 | github.com/hashicorp/go-plugin v1.4.3 // indirect 55 | github.com/hashicorp/go-retryablehttp v0.7.0 // indirect 56 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 57 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 58 | github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect 59 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.2 // indirect 60 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 61 | github.com/hashicorp/go-slug v0.7.0 // indirect 62 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 63 | github.com/hashicorp/go-uuid v1.0.2 // indirect 64 | github.com/hashicorp/go-version v1.4.0 // indirect 65 | github.com/hashicorp/golang-lru v0.5.4 // indirect 66 | github.com/hashicorp/hcl v1.0.0 // indirect 67 | github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect 68 | github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect 69 | github.com/hashicorp/vault/sdk v0.3.0 // indirect 70 | github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect 71 | github.com/jchavannes/go-pgp v0.0.0-20200131171414-e5978e6d02b4 // indirect 72 | github.com/jmespath/go-jmespath v0.4.0 // indirect 73 | github.com/klauspost/compress v1.14.2 // indirect 74 | github.com/kylelemons/godebug v1.1.0 // indirect 75 | github.com/mattn/go-colorable v0.1.12 // indirect 76 | github.com/mattn/go-isatty v0.0.14 // indirect 77 | github.com/mitchellh/copystructure v1.2.0 // indirect 78 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 79 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 80 | github.com/mitchellh/mapstructure v1.4.3 // indirect 81 | github.com/mitchellh/panicwrap v1.0.0 // indirect 82 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 83 | github.com/oklog/run v1.1.0 // indirect 84 | github.com/pierrec/lz4 v2.6.1+incompatible // indirect 85 | github.com/pmezard/go-difflib v1.0.0 // indirect 86 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 87 | github.com/ryanuber/go-glob v1.0.0 // indirect 88 | github.com/spf13/afero v1.8.1 // indirect 89 | github.com/ulikunitz/xz v0.5.10 // indirect 90 | go.opencensus.io v0.23.0 // indirect 91 | go.uber.org/atomic v1.9.0 // indirect 92 | golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 // indirect 93 | golang.org/x/mod v0.5.1 // indirect 94 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect 95 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect 96 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect 97 | golang.org/x/text v0.3.7 // indirect 98 | golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect 99 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 100 | google.golang.org/api v0.68.0 // indirect 101 | google.golang.org/appengine v1.6.7 // indirect 102 | google.golang.org/genproto v0.0.0-20220210181026-6fee9acbd336 // indirect 103 | google.golang.org/grpc v1.44.0 // indirect 104 | google.golang.org/protobuf v1.27.1 // indirect 105 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 107 | ) 108 | 109 | replace github.com/hashicorp/terraform => github.com/mvisonneau/terraform v1.1.0-alpha20210811.0.20210825144159-8012569bcac4 110 | -------------------------------------------------------------------------------- /pkg/schemas/config_test.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/openlyinc/pointy" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var testConfig = &Config{ 13 | TerraformVariables: Variables{ 14 | &Variable{ 15 | Name: "foo", 16 | }, 17 | }, 18 | EnvironmentVariables: Variables{ 19 | &Variable{ 20 | Name: "bar", 21 | }, 22 | }, 23 | Defaults: &Defaults{ 24 | Variable: &VariableDefaults{ 25 | TTL: pointy.String("15m"), 26 | }, 27 | }, 28 | } 29 | 30 | const ( 31 | fifteenMinuteDuration = time.Minute * 15 32 | thirtyMinuteDuration = time.Minute * 30 33 | ) 34 | 35 | func TestConfigGetVariables(t *testing.T) { 36 | variables := testConfig.GetVariables() 37 | assert.Len(t, variables, 2) 38 | 39 | assert.Equal(t, Variable{ 40 | Kind: VariableKindTerraform, 41 | Name: "foo", 42 | }, *(variables[0])) 43 | 44 | assert.Equal(t, Variable{ 45 | Kind: VariableKindEnvironment, 46 | Name: "bar", 47 | }, *(variables[1])) 48 | } 49 | 50 | func TestConfigGetVariableTTL(t *testing.T) { 51 | // Defining the TTL in the Variable 52 | ttl, err := testConfig.GetVariableTTL(&Variable{TTL: pointy.String("30m")}) 53 | assert.NoError(t, err) 54 | assert.Equal(t, thirtyMinuteDuration, ttl) 55 | 56 | // With an incorrect value in the variable definition 57 | _, err = testConfig.GetVariableTTL(&Variable{TTL: pointy.String("foo")}) 58 | assert.Error(t, err) 59 | 60 | // Using the default configuration value 61 | ttl, err = testConfig.GetVariableTTL(&Variable{}) 62 | assert.NoError(t, err) 63 | assert.Equal(t, fifteenMinuteDuration, ttl) 64 | 65 | // With an incorrect value in the default config definition 66 | incorrectDefaultConfig := &Config{ 67 | Defaults: &Defaults{ 68 | Variable: &VariableDefaults{ 69 | TTL: pointy.String("foo"), 70 | }, 71 | }, 72 | } 73 | 74 | _, err = incorrectDefaultConfig.GetVariableTTL(&Variable{}) 75 | assert.Error(t, err) 76 | 77 | // Without any configuration 78 | emptyConfig := &Config{} 79 | ttl, err = emptyConfig.GetVariableTTL(&Variable{}) 80 | assert.NoError(t, err) 81 | assert.Equal(t, time.Duration(0), ttl) 82 | } 83 | 84 | func TestComputeNewVariableExpirations(t *testing.T) { 85 | existingVariableExpirations := VariableExpirations{} 86 | updatedVariables := Variables{ 87 | &Variable{ 88 | Kind: VariableKindEnvironment, 89 | Name: "foo", 90 | }, 91 | } 92 | 93 | variableExpirations, hasChanges, err := testConfig.ComputeNewVariableExpirations(updatedVariables, existingVariableExpirations) 94 | assert.NoError(t, err) 95 | assert.True(t, hasChanges) 96 | assert.NotNil(t, variableExpirations) 97 | assert.NotNil(t, variableExpirations[VariableKindEnvironment]) 98 | assert.NotNil(t, variableExpirations[VariableKindEnvironment]["foo"]) 99 | assert.Equal(t, fifteenMinuteDuration, variableExpirations[VariableKindEnvironment]["foo"].TTL) 100 | assert.True(t, variableExpirations[VariableKindEnvironment]["foo"].ExpireAt.After(time.Now())) 101 | assert.True(t, variableExpirations[VariableKindEnvironment]["foo"].ExpireAt.Before(time.Now().Add(thirtyMinuteDuration))) 102 | 103 | // Already existent expiration variable and no changes 104 | existingVariableExpirations = make(VariableExpirations) 105 | existingVariableExpirations[VariableKindTerraform] = make(map[string]*VariableExpiration) 106 | existingVariableExpirations[VariableKindTerraform]["foo"] = &VariableExpiration{ 107 | TTL: fifteenMinuteDuration, 108 | ExpireAt: time.Now().Add(fifteenMinuteDuration), 109 | } 110 | 111 | _, hasChanges, err = testConfig.ComputeNewVariableExpirations(Variables{}, existingVariableExpirations) 112 | assert.NoError(t, err) 113 | assert.False(t, hasChanges) 114 | 115 | // No TTL defined on an updatedVariable 116 | emptyConfig := &Config{} 117 | updatedVariables = Variables{ 118 | &Variable{ 119 | Kind: VariableKindTerraform, 120 | Name: "foo", 121 | }, 122 | } 123 | 124 | variableExpirations, hasChanges, err = emptyConfig.ComputeNewVariableExpirations(updatedVariables, existingVariableExpirations) 125 | assert.NoError(t, err) 126 | assert.True(t, hasChanges) 127 | assert.Len(t, variableExpirations, 0) 128 | fmt.Println(variableExpirations) 129 | 130 | // Invalid TTL of an updated variable 131 | updatedVariables = Variables{ 132 | &Variable{ 133 | Name: "foo", 134 | TTL: pointy.String("bar"), 135 | }, 136 | } 137 | _, _, err = testConfig.ComputeNewVariableExpirations(updatedVariables, existingVariableExpirations) 138 | assert.Error(t, err) 139 | } 140 | 141 | func TestConfigGetVariablesToUpdate(t *testing.T) { 142 | // Empty expiration variables, we update all variables in the config 143 | variableExpirations := VariableExpirations{} 144 | variables, err := testConfig.GetVariablesToUpdate(variableExpirations) 145 | assert.NoError(t, err) 146 | assert.Equal(t, testConfig.GetVariables(), variables) 147 | 148 | // Expiration still in range 149 | variableExpirations = make(VariableExpirations) 150 | variableExpirations[VariableKindTerraform] = make(map[string]*VariableExpiration) 151 | variableExpirations[VariableKindTerraform]["foo"] = &VariableExpiration{ 152 | TTL: fifteenMinuteDuration, 153 | ExpireAt: time.Now().Add(fifteenMinuteDuration), 154 | } 155 | variables, err = testConfig.GetVariablesToUpdate(variableExpirations) 156 | assert.NoError(t, err) 157 | assert.Len(t, variables, 1) 158 | assert.Equal(t, "bar", variables[0].Name) 159 | 160 | // Invalid variable TTL 161 | invalidVariableTTLConfig := &Config{ 162 | TerraformVariables: Variables{ 163 | &Variable{ 164 | Name: "foo", 165 | TTL: pointy.String("bar"), 166 | }, 167 | }, 168 | } 169 | _, err = invalidVariableTTLConfig.GetVariablesToUpdate(variableExpirations) 170 | assert.Error(t, err) 171 | } 172 | -------------------------------------------------------------------------------- /internal/cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclsimple" 11 | "github.com/mvisonneau/go-helpers/logger" 12 | "github.com/mvisonneau/tfcw/pkg/functions" 13 | "github.com/mvisonneau/tfcw/pkg/schemas" 14 | "github.com/mvisonneau/tfcw/pkg/terraform" 15 | "github.com/mvisonneau/tfcw/pkg/tfcw" 16 | log "github.com/sirupsen/logrus" 17 | "github.com/urfave/cli/v2" 18 | "github.com/zclconf/go-cty/cty/function" 19 | ) 20 | 21 | var start time.Time 22 | 23 | func configure(ctx *cli.Context) (c *tfcw.Client, cfg *schemas.Config, err error) { 24 | start = ctx.App.Metadata["startTime"].(time.Time) 25 | 26 | if err = logger.Configure(logger.Config{ 27 | Level: ctx.String("log-level"), 28 | Format: ctx.String("log-format"), 29 | }); err != nil { 30 | return 31 | } 32 | 33 | cfg = &schemas.Config{ 34 | Runtime: schemas.Runtime{ 35 | WorkingDir: ctx.String("working-dir"), 36 | }, 37 | } 38 | 39 | tfcwConfigFile := computeConfigFilePath(cfg.Runtime.WorkingDir, ctx.String("config-file")) 40 | log.Debugf("Using config file at %s", tfcwConfigFile) 41 | 42 | // Create an EvalContext to define functions that we can use within the HCL for interpolation 43 | evalCtx := &hcl.EvalContext{ 44 | Functions: map[string]function.Function{ 45 | "env": functions.EnvFunction, 46 | }, 47 | } 48 | 49 | err = hclsimple.DecodeFile(tfcwConfigFile, evalCtx, cfg) 50 | if err != nil { 51 | return c, cfg, fmt.Errorf("tfcw config/hcl: %s", err.Error()) 52 | } 53 | 54 | if err = computeRuntimeConfigurationForTFC(cfg, ctx); err != nil { 55 | return 56 | } 57 | 58 | c, err = tfcw.NewClient(cfg) 59 | return 60 | } 61 | 62 | func exit(exitCode int, err error) cli.ExitCoder { 63 | defer log.WithFields( 64 | log.Fields{ 65 | "execution-time": time.Since(start), 66 | }, 67 | ).Debug("exited..") 68 | 69 | if err != nil { 70 | log.Error(err.Error()) 71 | } 72 | 73 | return cli.NewExitError("", exitCode) 74 | } 75 | 76 | // ExecWrapper gracefully logs and exits our `run` functions 77 | func ExecWrapper(f func(ctx *cli.Context) (int, error)) cli.ActionFunc { 78 | return func(ctx *cli.Context) error { 79 | return exit(f(ctx)) 80 | } 81 | } 82 | 83 | func computeConfigFilePath(workingDir, configFile string) string { 84 | return strings.NewReplacer("", workingDir).Replace(configFile) 85 | } 86 | 87 | func computeRuntimeConfigurationForTFC(cfg *schemas.Config, ctx *cli.Context) (err error) { 88 | if cfg.TFC == nil { 89 | cfg.TFC = &schemas.TFC{} 90 | } 91 | 92 | if cfg.TFC.Workspace == nil { 93 | cfg.TFC.Workspace = &schemas.Workspace{} 94 | } 95 | 96 | // Address 97 | cfg.Runtime.TFC.Address, err = computeRuntimeTFCAddress(cfg.Runtime.WorkingDir, ctx.String("address"), cfg.TFC.Address) 98 | if err != nil { 99 | return 100 | } 101 | 102 | // Token 103 | cfg.Runtime.TFC.Token, err = computeRuntimeTFCToken(cfg.Runtime.WorkingDir, ctx.String("token"), cfg.TFC.Token) 104 | if err != nil { 105 | return 106 | } 107 | 108 | // Organization 109 | cfg.Runtime.TFC.Organization, err = computeRuntimeTFCOrganization(cfg.Runtime.WorkingDir, ctx.String("organization"), cfg.TFC.Organization) 110 | if err != nil { 111 | return 112 | } 113 | 114 | // Workspace 115 | cfg.Runtime.TFC.Workspace, err = computeRuntimeTFCWorkspace(cfg.Runtime.WorkingDir, ctx.String("workspace"), cfg.TFC.Workspace.Name) 116 | return 117 | } 118 | 119 | func computeRuntimeTFCAddress(workingDir, flagValue string, tfcwValue *string) (string, error) { 120 | if flagValue != "" { 121 | log.Debugf("Using TFC address '%s' from CLI flag (or env variable)", returnHTTPSPrefixedURL(flagValue)) 122 | return returnHTTPSPrefixedURL(flagValue), nil 123 | } 124 | 125 | if tfcwValue != nil { 126 | log.Debugf("Using TFC address '%s' from TFCW config", returnHTTPSPrefixedURL(*tfcwValue)) 127 | return returnHTTPSPrefixedURL(*tfcwValue), nil 128 | } 129 | 130 | rbc, err := terraform.GetRemoteBackendConfig(workingDir) 131 | if err != nil { 132 | return "", err 133 | } 134 | 135 | if rbc != nil && rbc.Hostname != "" { 136 | log.Debugf("Using TFC address '%s' from Terraform remote backend configuration", returnHTTPSPrefixedURL(rbc.Hostname)) 137 | return returnHTTPSPrefixedURL(rbc.Hostname), nil 138 | } 139 | 140 | log.Debug("Using default TFC address 'https://app.terraform.io'") 141 | return "https://app.terraform.io", nil 142 | } 143 | 144 | func computeRuntimeTFCToken(workingDir, flagValue string, tfcwValue *string) (string, error) { 145 | if flagValue != "" { 146 | log.Debug("Using TFC token '***' from CLI flag (or env variable)") 147 | return flagValue, nil 148 | } 149 | 150 | if tfcwValue != nil { 151 | log.Debug("Using TFC token '***' from TFCW config") 152 | return *tfcwValue, nil 153 | } 154 | 155 | rbc, err := terraform.GetRemoteBackendConfig(workingDir) 156 | if err != nil { 157 | return "", err 158 | } 159 | 160 | if rbc != nil && rbc.Token != "" { 161 | log.Debug("Using TFC token '***' from Terraform remote backend configuration") 162 | return rbc.Token, nil 163 | } 164 | 165 | // By not returning an empty string, we allow the TFCW to be initiated, yay! 166 | return "_", nil 167 | } 168 | 169 | func computeRuntimeTFCOrganization(workingDir, flagValue string, tfcwValue *string) (string, error) { 170 | if flagValue != "" { 171 | log.Debugf("Using TFC organization '%s' from CLI flag (or env variable)", flagValue) 172 | return flagValue, nil 173 | } 174 | 175 | if tfcwValue != nil { 176 | log.Debugf("Using TFC organization '%s' from TFCW config", *tfcwValue) 177 | return *tfcwValue, nil 178 | } 179 | 180 | rbc, err := terraform.GetRemoteBackendConfig(workingDir) 181 | if err != nil { 182 | return "", err 183 | } 184 | 185 | if rbc != nil && rbc.Organization != "" { 186 | log.Debugf("Using TFC organization '%s' from Terraform remote backend configuration", rbc.Organization) 187 | return rbc.Organization, nil 188 | } 189 | 190 | return "", nil 191 | } 192 | 193 | func computeRuntimeTFCWorkspace(workingDir, flagValue string, tfcwValue *string) (string, error) { 194 | if flagValue != "" { 195 | log.Debugf("Using TFC workspace '%s' from CLI flag (or env variable)", flagValue) 196 | return flagValue, nil 197 | } 198 | 199 | if tfcwValue != nil { 200 | log.Debugf("Using TFC workspace '%s' from TFCW config", *tfcwValue) 201 | return *tfcwValue, nil 202 | } 203 | 204 | rbc, err := terraform.GetRemoteBackendConfig(workingDir) 205 | if err != nil { 206 | return "", err 207 | } 208 | 209 | if rbc != nil && rbc.Workspace != "" { 210 | log.Debugf("Using TFC workspace '%s' from Terraform remote backend configuration", rbc.Workspace) 211 | return rbc.Workspace, nil 212 | } 213 | 214 | return "", nil 215 | } 216 | 217 | func returnHTTPSPrefixedURL(url string) string { 218 | re := regexp.MustCompile(`^(http|https)://`) 219 | if re.MatchString(url) { 220 | return url 221 | } 222 | return fmt.Sprintf("https://%s", url) 223 | } 224 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [0ver](https://0ver.org). 7 | 8 | ## [Unreleased] 9 | 10 | ## [v0.0.13] - 2022-02-11 11 | 12 | ### Added 13 | 14 | - Release container images to `quay.io` 15 | 16 | ### Changed 17 | 18 | - Bumped all dependencies 19 | 20 | ## [v0.0.12] - 2021-08-27 21 | 22 | ### Added 23 | 24 | - `snapcraft` releases 25 | - pre-releases on every commit made upon the default branch (main) 26 | 27 | ### Changed 28 | 29 | - Upgraded all dependencies to their most recent versions 30 | - Upgraded to go `1.17` 31 | - Disabled some tests related to Vault in order to not have to import the full project as a dependency anymore 32 | - Forked the `hashicorp/terraform` to be able to continue leveraging the **configs** lib 33 | 34 | ### Removed 35 | 36 | - `freebsd` and `solaris` releases 37 | 38 | ## [v0.0.11] - 2020-12-17 39 | 40 | ### Added 41 | 42 | - Release GitHub container registry based images: [ghcr.io/mvisonneau/tfcw](https://github.com/users/mvisonneau/packages/container/package/tfcw) 43 | - Release `arm64v8` based container images as part of docker manifests in both **docker.io** and **ghcr.io** 44 | - GPG sign released artifacts checksums 45 | 46 | ### Changed 47 | 48 | - Updated all dependencies / Terraform to `0.14.2` 49 | - Migrated CI from Drone to GitHub actions 50 | 51 | ## [v0.0.10] - 2020-10-30 52 | 53 | ### Changed 54 | 55 | - Prefix new releases with `^v` to make pkg.go.dev happy 56 | - Upgraded all dependencies 57 | - Refactored codebase following golang file structure 58 | - Fixed goreleaser config 59 | 60 | ## [0.0.9] - 2020-09-14 61 | 62 | ### Added 63 | 64 | - gosec tests 65 | 66 | ### Changed 67 | 68 | - default branch from `master` to `main` 69 | - Bumped urfave/cli to v2 70 | - upgraded all dependencies to their latest version, in particular: 71 | - go to **1.15** 72 | - terraform to **0.13.2** 73 | - go-tfe to **0.10.1** 74 | - fixed some missed error handlings 75 | 76 | ## [0.0.8] - 2020-04-14 77 | 78 | ### Added 79 | 80 | - New CLI command to be able to remove variables configured on the workspace: `workspace delete-variables` 81 | - A couple commands to enable/disable the operations on the workspace from the CLI 82 | 83 | ### Changed 84 | 85 | - Renamed some CLI commands & helpers 86 | 87 | ## [0.0.7] - 2020-04-09 88 | 89 | ### Changed 90 | 91 | - **provider/vault** - **Actually** fixed a bug on the default vault token validation method 92 | - Fixed a panic occurring when defaults.var block was not defined 93 | - Fixed a bug on the default definition of the HCL flag for the variable 94 | - Prevent variables from being removed whilst using var.ttl field and tfc.purge-unmanaged-variables at the same time 95 | - Prevent variables from being removed when using tfc.purge-unmanaged-variables with a Vault multi-key variable 96 | - Better testing for the variable ttl management and couple bugfixes 97 | - Automated the variable refresh (& removal if empty) when their TTL is modified 98 | 99 | ## [0.0.6] - 2020-04-07 100 | 101 | ### Added 102 | 103 | - **provider/vault** - Added support for kv-v2 secret engine 104 | - Return an error on run creation if remote runs are not enabled on the workspace 105 | - Do not prompt for approval and follow the apply logs if the workspace is configured with AutoApply 106 | - Validate there is no pending run on the workspace before attempting to create a new one 107 | 108 | ### Changed 109 | 110 | - **provider/vault** - Fixed a bug on the default vault token validation method 111 | - Refactored part of the CLI around the render function 112 | - Corrected the expected/actual variables ordering in tests 113 | - Ordered commands, subcommands and flags alphabetically in the CLI helpers 114 | - Fixed some regression/bug introduced on the ConfigureWorkspace function in `0.0.5` preventing it from working properly 115 | 116 | ## [0.0.5] - 2020-04-06 117 | 118 | ### Added 119 | 120 | - Organization and workspace configuration can now either be set directly through respective flags `--organization` / `--workspace` 121 | - TFC configuration (address, token, organization & workspace)will default to what is configured as a remote backend in the Terraform configuration 122 | - Defaults configuration capabilities for variable `sensitive` & `hcl` fields 123 | - A `ttl` field on variables which makes TFCW only update some variables when their Time To Live has been exceeded. This results in much faster syncs 124 | - Speed improvements by reducing the amount of calls to fetch the workspace ID. Combined with the TTL option, when nothing is needed the overhead is now only about a second 125 | 126 | ## [0.0.4] - 2020-04-01 127 | 128 | ### Added 129 | 130 | - Capability to manage the workspace SSH key 131 | - Added a timeout flag to automatically exit if the run is too long to start 132 | 133 | ### Changed 134 | 135 | - Reviewed the examples and configuration syntax documentation 136 | - Bumped go modules versions 137 | 138 | ## [0.0.3] - 2020-03-21 139 | 140 | ### Added 141 | 142 | - Support for managing some workspace configuration within the tfcw.hcl file 143 | 144 | ### Changed 145 | 146 | - Bumped to go 1.14 / goreleaser 0.129.0 147 | - Fixed a bug preventing the Vault provider from working properly when using multiple values 148 | - Fixed a bug preventing errors from being returned on provider deciphering failures 149 | 150 | ## [0.0.2] - 2020-02-27 151 | 152 | ### Added 153 | 154 | - Custom name for runs 155 | - Workspace status and current-run-id commands 156 | - Refactored the CLI for creating runs 157 | - Added standalone commands for approving or discarding runs 158 | - Covered ~40% of the codebase with unit tests 159 | - Added the possibility to export the runID into a file when created 160 | 161 | ## [0.0.1] - 2020-02-18 162 | 163 | ### Added 164 | 165 | - Read configuration form HCL (or json) file 166 | - Fetch sensitive values from 3 providers : `vault`, `s5` and `environment variables` 167 | - Plan and apply Terraform stacks 168 | - dry-run feature on render function 169 | - purge unmanaged variables 170 | 171 | [Unreleased]: https://github.com/mvisonneau/tfcw/compare/v0.0.13...HEAD 172 | [v0.0.13]: https://github.com/mvisonneau/tfcw/tree/v0.0.13 173 | [v0.0.12]: https://github.com/mvisonneau/tfcw/tree/v0.0.12 174 | [v0.0.11]: https://github.com/mvisonneau/tfcw/tree/v0.0.11 175 | [v0.0.10]: https://github.com/mvisonneau/tfcw/tree/v0.0.10 176 | [0.0.9]: https://github.com/mvisonneau/tfcw/tree/0.0.9 177 | [0.0.8]: https://github.com/mvisonneau/tfcw/tree/0.0.8 178 | [0.0.7]: https://github.com/mvisonneau/tfcw/tree/0.0.7 179 | [0.0.6]: https://github.com/mvisonneau/tfcw/tree/0.0.6 180 | [0.0.5]: https://github.com/mvisonneau/tfcw/tree/0.0.5 181 | [0.0.4]: https://github.com/mvisonneau/tfcw/tree/0.0.4 182 | [0.0.3]: https://github.com/mvisonneau/tfcw/tree/0.0.3 183 | [0.0.2]: https://github.com/mvisonneau/tfcw/tree/0.0.2 184 | [0.0.1]: https://github.com/mvisonneau/tfcw/tree/0.0.1 185 | -------------------------------------------------------------------------------- /pkg/providers/s5/pgp_test.go: -------------------------------------------------------------------------------- 1 | package s5 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | "github.com/mvisonneau/s5/pkg/cipher" 10 | cipherPGP "github.com/mvisonneau/s5/pkg/cipher/pgp" 11 | "github.com/mvisonneau/tfcw/pkg/schemas" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | const testPGPPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- 16 | 17 | mQINBF5Slx8BEADISfn9MzCsrAvLonPhwAYVlEFWWqk3Z6gZQekwdsTp6tyQWYWD 18 | J1I7yzGr05tUKUAvrNaCfWH6syh8sHVp+7iYGJjDzbPIl+yn+8grCWhEa0+23iPw 19 | EBX+Q5iTlp2ZmwIpbD7/XL0Y/dsJZID80k4lhPxIPbH2a+cPOIstmtGhXKJPBItA 20 | I2G/fSwWJpivGLeYrIXJxHAkCG9GTnmo2u+s+Kl9A5m7zOPSgnV+Wcl+ofX6RMDZ 21 | qScTsaq+NfyEH0rC7lSvn+BxrBP8rjP4abtB1EpiAJaZBnu7Vj8d7YrtrTu5eWnX 22 | cO4C1sd84AB+Gz4M+6lBaWnTvSOWfWJeM6O4qa++mjnO4bN4cRrneiEz1/vuzL5o 23 | NF5ky04Ro16MmDN3XmzXidCUJaEBjZ3vfxTqZ9RQqWX3Kzpl7bN/OzXXBcd4EFgv 24 | FULhiwquYAG++0PrPLOMEAZhaPpNKHAiz1Hxiztai1WT+CLBQWz+ERLpk7ZoPRTd 25 | ZqMopZcOX821d28AxDi80X/Imhd37hkSEwfMjW3ZKmNStSzXoTUriE4GZIGIJa33 26 | xyhgRtO/eRB+I3XnEU2BEdAqiI8nDRA2p4myZE9EtS1hqsCdYnTZrbxrMbuxBYvO 27 | rLfbAS/dfvqPkB8K0VIZuH4m8vqeZ+vwhOkYVj7pU6eoTTffh6x4zNGebQARAQAB 28 | tBVGb28gQmFyIDxmb29AYmFyLmNvbT6JAk4EEwEIADgWIQT92jKxeZxZcBEPNm+N 29 | 0lu3UxLgYQUCXlKXHwIbLwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCN0lu3 30 | UxLgYSOvEACUSCl8XAVaK31XiUk5qQqef9BXhM6Hko5mQYQUZ2XAGzOJ7HJiaja0 31 | RW0oSpqvdIwEdHkxWPxkmTY7QpWN9pm9U+XXOJlL0BcNvqVFOmIuTawqlRWSOq1z 32 | QY/Y6951vovJVfVCRFSUgfsvXFkTBT+ke5wytyf8Y36agldNyAA3KJ4ykEi8pNTP 33 | fXrABabJbFG+MqFkDR/9bwzhj5AXpJXrZxtojYP+QMI1H2xSkT0rg0gPJaa87Fn7 34 | g6ImLSHRZ9L/35uS5ieLPC9g94M3o9NCdLJagfKnq8OgEzQfrroF9qndOl3rvtz2 35 | hD+lB7xS2StUAOBgXrVCBS/hVNglHGAqZgFRsRZTmkMIzjFw27fVi0UuPB96M8dr 36 | HzED29MD7r1OeFlpKLrYDKkWkn9RJw04cA7uMWqF2SREbXAy6dAD6+Rj2riKwaoo 37 | HJ9sB7ZnOJ7eFtOroiLe/RZfy30khM5pfs6dsmRH9od+cb6d6uEHgC74LZdZKI7R 38 | L/lhQ0S+LoqCqjqt88FSdY91P4p6mSUuOAwx0uwH++Q2FwG/0E8f8Xhd0ktgC8S7 39 | iTz7MehdL+CFeb0eBQsylgh4naslFUky3CvqKtm5fwYO1I7fsJUM9UjUZsYu1PSw 40 | +1cywObMR/cznH3QV3cLl40WjVUoVG+jAINcqnZNlU6Xjz6nIrxwHg== 41 | =JdiX 42 | -----END PGP PUBLIC KEY BLOCK-----` 43 | 44 | const testPGPPrivateKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- 45 | 46 | lQcYBF5Slx8BEADISfn9MzCsrAvLonPhwAYVlEFWWqk3Z6gZQekwdsTp6tyQWYWD 47 | J1I7yzGr05tUKUAvrNaCfWH6syh8sHVp+7iYGJjDzbPIl+yn+8grCWhEa0+23iPw 48 | EBX+Q5iTlp2ZmwIpbD7/XL0Y/dsJZID80k4lhPxIPbH2a+cPOIstmtGhXKJPBItA 49 | I2G/fSwWJpivGLeYrIXJxHAkCG9GTnmo2u+s+Kl9A5m7zOPSgnV+Wcl+ofX6RMDZ 50 | qScTsaq+NfyEH0rC7lSvn+BxrBP8rjP4abtB1EpiAJaZBnu7Vj8d7YrtrTu5eWnX 51 | cO4C1sd84AB+Gz4M+6lBaWnTvSOWfWJeM6O4qa++mjnO4bN4cRrneiEz1/vuzL5o 52 | NF5ky04Ro16MmDN3XmzXidCUJaEBjZ3vfxTqZ9RQqWX3Kzpl7bN/OzXXBcd4EFgv 53 | FULhiwquYAG++0PrPLOMEAZhaPpNKHAiz1Hxiztai1WT+CLBQWz+ERLpk7ZoPRTd 54 | ZqMopZcOX821d28AxDi80X/Imhd37hkSEwfMjW3ZKmNStSzXoTUriE4GZIGIJa33 55 | xyhgRtO/eRB+I3XnEU2BEdAqiI8nDRA2p4myZE9EtS1hqsCdYnTZrbxrMbuxBYvO 56 | rLfbAS/dfvqPkB8K0VIZuH4m8vqeZ+vwhOkYVj7pU6eoTTffh6x4zNGebQARAQAB 57 | AA/8Cj5WVClpEa7rteqrq8REmDGKTrgf5wVP88xXxWXECzg60M5ZslJ+TzArBmAY 58 | Yp6IrNq76Dww/z+bqeNKFGiOXiEFg+yIusQ7+bKgrMRnZvbO3EoFY0+MGgJen4KL 59 | 5U5BnXK7nUqiu50U6PV3iPamKB8Vpzz+70NBGjMvH976KufwJfbUISyhR2GuGCS5 60 | mSlV3BYYWMn8kXgCkOfBXlRymGsc5/4ZWVSD4ye1Vwf3WTJy84c1DpMrIqoeH52A 61 | bUilOM3Np2NkeQvVXdsrx0Z8Q3dv2Nrk2OC1aIH86mZUVtsne2q6C6dRiCc9JQxw 62 | mgKBeVKDRPHD1G0PXX2Xwu+eYOOR+Lty6wzgI/yQgmLL+vZQr3vfcPUJLCVVwWAu 63 | 284YREQwaYWjMuTort0bl7SbiseAZHiigWXOQ88KZr1IIVyAGYi6Gv0Fpo2at8D8 64 | 33srlt6wnG1raJzDBKboGsU+2IByxg1pspPSGvurNSBKHnaccSGT4+fHt9bxO32Q 65 | zZW5tgfnWqXZ4A4y9luoDeQtk3iK6MB1Tkhl0rJZI3HO/VjniYY1uFqf5gaZleDT 66 | y8DOlDzfk1BXvsrkLYbZN8Wm5nnsDD4Apv2xmdCiUV6HiquYd7jnR2XjAS/bJVKE 67 | JBYFIoIBSGkrc8ktUqz2xgX6j3fm1oGWzuHk/leXD5k3JzUIANEKETfpTciSTwnE 68 | 7oJnacX0rtXhBmg0eyquTTOXAvq6bup7e00J0nPI2QN00OsvjQg+ntR2fDhJdASW 69 | hOp/MEB5mQ9DQM8Huk65GwFxxCjspb62Mx3//Jj33RdJsSyzWPyfa9emH+0toUHI 70 | PSSAJ3GIBzhCwIQmAzE4+hZeju9u9hv0pMcLkSRIBL95QALAstOthuVb7PswMrW2 71 | bMN0FF0X5tEUlABpzqI8tXFgd7VL0gWhYHMdEyh4sTTsDanL62oRFuyIBDK1U6Sy 72 | 4AUT58urJxUMZSAhCec9b2ejV2IQg3+v4idxWdvzK4eOObQvIoc10+1HVRBxfXzK 73 | Q8LvfeMIAPVIrEhbli28mWUJhefLzVjq7exoKBDknBDvqhN3vWrgEYHGu8f01RYR 74 | XeHKiIcXcDwxoLS8nFzupw2e3ab/rgJHYF6Me3hco4by3xylqmDS/7k0we5DtOs6 75 | WDZs5z6A46yWwvwcF63JZCyw/VSv0NGI0Eh9MNBPMkvprarDADX2zald8YvnQztZ 76 | ol3HR+U2GGM3wJdEYZQ31D7GFO0VhdOH7zFuj3PuvlyYpwOaeRozaXvedfRULNG1 77 | TrzBuVp4+4IAPWwCRTskTQHr69DS8sLOX0cMQj+OIn/LBW9ScCbgMEjOIe7CdZM5 78 | kC7ohb08fBMUQ7ph0RFu0l0Uhm9sI28IAI0n/YfGPI1ELrXHC00x87ETobczIr4W 79 | APZOl4CpJujWFUHGRtRticWUs0v5nmPODf1ieshuA8PwNJCBYTBN/33x09ygAdym 80 | huJa8u4v4I9iHcFl9l6MwcMU/WzrvPYv0EWO2bbRmW4UUbHXnXQFed/v6psV9dgI 81 | OlZbbE+5MUyARgG00utw9cDmwQT0pOK1H5ksBPKsMLo8K/o31vKuLBAYIISZ8srg 82 | gu7hb/vx8Kl6+226yEbGADli5/aXx2kGJo36j5Udx8qWXwiJQl0zjLTAGRhFxVDE 83 | eHAmlXT0rUpRXI7/7jKwl621G+E57GF/x0FG6bkJvJIzjPJCOmXfBN6LxrQVRm9v 84 | IEJhciA8Zm9vQGJhci5jb20+iQJOBBMBCAA4FiEE/doysXmcWXARDzZvjdJbt1MS 85 | 4GEFAl5Slx8CGy8FCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQjdJbt1MS4GEj 86 | rxAAlEgpfFwFWit9V4lJOakKnn/QV4TOh5KOZkGEFGdlwBsziexyYmo2tEVtKEqa 87 | r3SMBHR5MVj8ZJk2O0KVjfaZvVPl1ziZS9AXDb6lRTpiLk2sKpUVkjqtc0GP2Ove 88 | db6LyVX1QkRUlIH7L1xZEwU/pHucMrcn/GN+moJXTcgANyieMpBIvKTUz316wAWm 89 | yWxRvjKhZA0f/W8M4Y+QF6SV62cbaI2D/kDCNR9sUpE9K4NIDyWmvOxZ+4OiJi0h 90 | 0WfS/9+bkuYnizwvYPeDN6PTQnSyWoHyp6vDoBM0H666Bfap3Tpd677c9oQ/pQe8 91 | UtkrVADgYF61QgUv4VTYJRxgKmYBUbEWU5pDCM4xcNu31YtFLjwfejPHax8xA9vT 92 | A+69TnhZaSi62AypFpJ/UScNOHAO7jFqhdkkRG1wMunQA+vkY9q4isGqKByfbAe2 93 | Zzie3hbTq6Ii3v0WX8t9JITOaX7OnbJkR/aHfnG+nerhB4Au+C2XWSiO0S/5YUNE 94 | vi6Kgqo6rfPBUnWPdT+KepklLjgMMdLsB/vkNhcBv9BPH/F4XdJLYAvEu4k8+zHo 95 | XS/ghXm9HgULMpYIeJ2rJRVJMtwr6irZuX8GDtSO37CVDPVI1GbGLtT0sPtXMsDm 96 | zEf3M5x90Fd3C5eNFo1VKFRvowCDXKp2TZVOl48+pyK8cB4= 97 | =ueeG 98 | -----END PGP PRIVATE KEY BLOCK-----` 99 | 100 | func createTestPGPKeys(publicKey, privateKey string) (string, string, error) { 101 | tmpFilePublic, err := ioutil.TempFile(os.TempDir(), "tfcw-test-pgp-pub-") 102 | if err != nil { 103 | return "", "", err 104 | } 105 | 106 | if _, err = tmpFilePublic.Write([]byte(publicKey)); err != nil { 107 | return "", "", fmt.Errorf("Failed to write to temporary file : %s", err.Error()) 108 | } 109 | 110 | if err = tmpFilePublic.Close(); err != nil { 111 | return "", "", fmt.Errorf("Failed to close temporary file : %s", err.Error()) 112 | } 113 | 114 | tmpFilePrivate, err := ioutil.TempFile(os.TempDir(), "tfcw-test-pgp-pri-") 115 | if _, err = tmpFilePrivate.Write([]byte(privateKey)); err != nil { 116 | return "", "", fmt.Errorf("Failed to write to temporary file : %s", err.Error()) 117 | } 118 | 119 | if err = tmpFilePrivate.Close(); err != nil { 120 | return "", "", fmt.Errorf("Failed to close temporary file : %s", err.Error()) 121 | } 122 | 123 | return tmpFilePublic.Name(), tmpFilePrivate.Name(), nil 124 | } 125 | 126 | func TestGetCipherEnginePGP(t *testing.T) { 127 | cipherEngineType := schemas.S5CipherEngineTypePGP 128 | publicKeyPath, privateKeyPath, err := createTestPGPKeys(testPGPPublicKey, testPGPPrivateKey) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | defer os.Remove(publicKeyPath) 133 | defer os.Remove(privateKeyPath) 134 | 135 | // expected engine 136 | expectedEngine, err := cipher.NewPGPClient(publicKeyPath, privateKeyPath) 137 | assert.Nil(t, err) 138 | 139 | // all defined in client, empty variable config (default settings) 140 | v := &schemas.S5{} 141 | c := &Client{ 142 | CipherEngineType: &cipherEngineType, 143 | CipherEnginePGP: &schemas.S5CipherEnginePGP{ 144 | PublicKeyPath: &publicKeyPath, 145 | PrivateKeyPath: &privateKeyPath, 146 | }, 147 | } 148 | 149 | cipherEngine, err := c.getCipherEngine(v) 150 | assert.Nil(t, err) 151 | assert.Equal(t, *expectedEngine.Entity.PrimaryKey, *cipherEngine.(*cipherPGP.Client).Entity.PrimaryKey) 152 | assert.Equal(t, *expectedEngine.Entity.PrivateKey, *cipherEngine.(*cipherPGP.Client).Entity.PrivateKey) 153 | 154 | // all defined in variable, empty client config 155 | c = &Client{} 156 | v = &schemas.S5{ 157 | CipherEngineType: &cipherEngineType, 158 | CipherEnginePGP: &schemas.S5CipherEnginePGP{ 159 | PublicKeyPath: &publicKeyPath, 160 | PrivateKeyPath: &privateKeyPath, 161 | }, 162 | } 163 | 164 | cipherEngine, err = c.getCipherEngine(v) 165 | assert.Nil(t, err) 166 | assert.Equal(t, *expectedEngine.Entity.PrimaryKey, *cipherEngine.(*cipherPGP.Client).Entity.PrimaryKey) 167 | assert.Equal(t, *expectedEngine.Entity.PrivateKey, *cipherEngine.(*cipherPGP.Client).Entity.PrivateKey) 168 | 169 | // key defined in environment variable 170 | os.Setenv("S5_PGP_PUBLIC_KEY_PATH", publicKeyPath) 171 | os.Setenv("S5_PGP_PRIVATE_KEY_PATH", privateKeyPath) 172 | c = &Client{} 173 | v = &schemas.S5{ 174 | CipherEngineType: &cipherEngineType, 175 | } 176 | 177 | cipherEngine, err = c.getCipherEngine(v) 178 | assert.Nil(t, err) 179 | assert.Equal(t, *expectedEngine.Entity.PrimaryKey, *cipherEngine.(*cipherPGP.Client).Entity.PrimaryKey) 180 | assert.Equal(t, *expectedEngine.Entity.PrivateKey, *cipherEngine.(*cipherPGP.Client).Entity.PrivateKey) 181 | } 182 | -------------------------------------------------------------------------------- /pkg/tfcw/workspaces.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "fmt" 5 | 6 | tfc "github.com/hashicorp/go-tfe" 7 | "github.com/mvisonneau/tfcw/pkg/schemas" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // GetWorkspace returns a workspace given its name and organization 12 | func (c *Client) GetWorkspace(organization, workspace string) (*tfc.Workspace, error) { 13 | log.Debug("Fetching workspace") 14 | w, err := c.TFC.Workspaces.Read(c.Context, organization, workspace) 15 | if err != nil { 16 | return nil, fmt.Errorf("error fetching TFC workspace: %s", err) 17 | } 18 | 19 | log.Debugf("Found workspace id for '%s': %s", w.Name, w.ID) 20 | 21 | return w, nil 22 | } 23 | 24 | func (c *Client) createWorkspace(cfg *schemas.Config) (*tfc.Workspace, error) { 25 | log.Debug("Creating workspace") 26 | opts := tfc.WorkspaceCreateOptions{ 27 | Name: &cfg.Runtime.TFC.Workspace, 28 | AutoApply: cfg.TFC.Workspace.AutoApply, 29 | TerraformVersion: cfg.TFC.Workspace.TerraformVersion, 30 | WorkingDirectory: cfg.TFC.Workspace.WorkingDirectory, 31 | } 32 | w, err := c.TFC.Workspaces.Create(c.Context, cfg.Runtime.TFC.Organization, opts) 33 | if err != nil { 34 | return nil, fmt.Errorf("error creating TFC workspace: %s", err) 35 | } 36 | 37 | log.Debugf("Workspace %s' created with ID : %s", w.Name, w.ID) 38 | 39 | return w, nil 40 | } 41 | 42 | // ConfigureWorkspace check and remediate the configuration of the configured workspace 43 | func (c *Client) ConfigureWorkspace(cfg *schemas.Config, dryRun bool) (w *tfc.Workspace, err error) { 44 | w, err = c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 45 | if err != nil { 46 | if err.Error() == "error fetching TFC workspace: resource not found" { 47 | if cfg.TFC.WorkspaceAutoCreate == nil || *cfg.TFC.WorkspaceAutoCreate { 48 | if !dryRun { 49 | log.Infof("workspace '%s' does not exist on organization '%s', creating it..", cfg.Runtime.TFC.Workspace, cfg.Runtime.TFC.Organization) 50 | w, err = c.createWorkspace(cfg) 51 | if err != nil { 52 | return 53 | } 54 | } else { 55 | log.Warnf("[DRY-RUN] - would have created the workspace as it does not currently exists") 56 | return nil, fmt.Errorf("exiting as workspace does not exist so we won't be able to simulate the dry run further") 57 | } 58 | } else { 59 | return nil, fmt.Errorf("workspace does not exist and auto-create is set to false") 60 | } 61 | } else { 62 | return 63 | } 64 | } 65 | 66 | log.Info("Checking workspace configuration") 67 | 68 | workspaceNeedToBeUpdated := false 69 | trueVar := true 70 | 71 | // Check if we actually need to trigger an update or not 72 | 73 | // If not explicitly set to false, we enforce workspace operations to true (remote executions) 74 | if cfg.TFC.Workspace.Operations == nil { 75 | cfg.TFC.Workspace.Operations = &trueVar 76 | } 77 | 78 | if *cfg.TFC.Workspace.Operations != w.Operations { 79 | workspaceNeedToBeUpdated = true 80 | log.Infof("Workspace operations configured with '%v', wanted '%v', we will update", w.Operations, *cfg.TFC.Workspace.Operations) 81 | } 82 | 83 | if cfg.TFC.Workspace.AutoApply != nil { 84 | if *cfg.TFC.Workspace.AutoApply != w.AutoApply { 85 | workspaceNeedToBeUpdated = true 86 | log.Infof("Workspace auto-apply configured with '%v', wanted '%v', we will update", w.AutoApply, *cfg.TFC.Workspace.AutoApply) 87 | } 88 | } 89 | 90 | if cfg.TFC.Workspace.TerraformVersion != nil { 91 | if *cfg.TFC.Workspace.TerraformVersion != w.TerraformVersion { 92 | workspaceNeedToBeUpdated = true 93 | log.Infof("Workspace terraform version configured with '%s', wanted '%s', we will update", w.TerraformVersion, *cfg.TFC.Workspace.TerraformVersion) 94 | } 95 | } 96 | 97 | if cfg.TFC.Workspace.WorkingDirectory != nil { 98 | if *cfg.TFC.Workspace.WorkingDirectory != w.WorkingDirectory { 99 | workspaceNeedToBeUpdated = true 100 | log.Infof("Workspace working directory configured with '%s', wanted '%s', we will update", w.WorkingDirectory, *cfg.TFC.Workspace.WorkingDirectory) 101 | } 102 | } 103 | 104 | if cfg.TFC.Workspace.SSHKey != nil { 105 | var shouldUpdateSSHKey bool 106 | shouldUpdateSSHKey, err = c.shouldUpdateSSHKey(w, *cfg.TFC.Workspace.SSHKey) 107 | if err != nil { 108 | return 109 | } 110 | 111 | if shouldUpdateSSHKey { 112 | if !dryRun { 113 | err = c.updateSSHKey(w, *cfg.TFC.Workspace.SSHKey) 114 | if err != nil { 115 | return w, fmt.Errorf("error updating TFC workspace ssh key: %s", err) 116 | } 117 | } else { 118 | log.Infof("[DRY-RUN] not actually updating workspace's SSH key configuration as we dry-run mode") 119 | } 120 | } 121 | } 122 | 123 | if workspaceNeedToBeUpdated { 124 | if !dryRun { 125 | opts := tfc.WorkspaceUpdateOptions{ 126 | Name: cfg.TFC.Workspace.Name, 127 | Operations: cfg.TFC.Workspace.Operations, 128 | AutoApply: cfg.TFC.Workspace.AutoApply, 129 | TerraformVersion: cfg.TFC.Workspace.TerraformVersion, 130 | WorkingDirectory: cfg.TFC.Workspace.WorkingDirectory, 131 | } 132 | 133 | w, err = c.TFC.Workspaces.UpdateByID(c.Context, w.ID, opts) 134 | if err != nil { 135 | return w, fmt.Errorf("error updating TFC workspace: %s", err) 136 | } 137 | 138 | if cfg.TFC.Workspace.SSHKey != nil { 139 | err = c.updateSSHKey(w, *cfg.TFC.Workspace.SSHKey) 140 | if err != nil { 141 | return w, fmt.Errorf("error updating TFC workspace ssh key: %s", err) 142 | } 143 | } 144 | } else { 145 | log.Infof("[DRY-RUN] not actually updating workspace configuration as we dry-run mode") 146 | } 147 | } 148 | 149 | return 150 | } 151 | 152 | // GetWorkspaceStatus returns the status of the configured workspace 153 | func (c *Client) GetWorkspaceStatus(cfg *schemas.Config) error { 154 | w, err := c.GetWorkspace(cfg.Runtime.TFC.Organization, cfg.Runtime.TFC.Workspace) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | if w.Locked { 160 | fmt.Printf("Workspace %s is currently locked by run ID '%s'\n", cfg.Runtime.TFC.Workspace, w.CurrentRun.ID) 161 | currentRun, err := c.TFC.Runs.Read(c.Context, w.CurrentRun.ID) 162 | if err != nil { 163 | return err 164 | } 165 | fmt.Printf("Status: %v\n", currentRun.Status) 166 | } else { 167 | fmt.Printf("Workspace %s is idle\n", cfg.Runtime.TFC.Workspace) 168 | } 169 | 170 | return nil 171 | } 172 | 173 | // GetWorkspaceCurrentRunID returns the status of the configured workspace 174 | func (c *Client) GetWorkspaceCurrentRunID(w *tfc.Workspace) (string, error) { 175 | if w.Locked { 176 | log.Debugf("Workspace %s is currently locked by run ID '%s'\n", w.ID, w.CurrentRun.ID) 177 | return w.CurrentRun.ID, nil 178 | } 179 | 180 | return "", fmt.Errorf("workspace %s is currently idle", w.ID) 181 | } 182 | 183 | // SetWorkspaceOperations update the workspace operations value 184 | func (c *Client) SetWorkspaceOperations(w *tfc.Workspace, operations bool) (err error) { 185 | opts := tfc.WorkspaceUpdateOptions{ 186 | Operations: tfc.Bool(operations), 187 | } 188 | 189 | _, err = c.TFC.Workspaces.UpdateByID(c.Context, w.ID, opts) 190 | return 191 | } 192 | 193 | // DeleteAllWorkspaceVariables delete the all the variables present on the workspace 194 | func (c *Client) DeleteAllWorkspaceVariables(w *tfc.Workspace) (err error) { 195 | existingVariables, _, _, err := c.listVariables(w) 196 | 197 | for _, vars := range existingVariables { 198 | for _, v := range vars { 199 | if err = c.TFC.Variables.Delete(c.Context, w.ID, v.ID); err != nil { 200 | return err 201 | } 202 | log.Infof("deleted variable %s", v.Key) 203 | } 204 | } 205 | 206 | return 207 | } 208 | 209 | // DeleteWorkspaceVariables delete the variables list passed as an argument if they are present 210 | // on the workspace 211 | func (c *Client) DeleteWorkspaceVariables(w *tfc.Workspace, variables schemas.Variables) (err error) { 212 | existingVariables, _, _, err := c.listVariables(w) 213 | 214 | for _, v := range variables { 215 | kind := getCategoryType(v.Kind) 216 | if _, ok := existingVariables[kind]; ok { 217 | if _, ok := existingVariables[kind][v.Name]; ok { 218 | if err = c.TFC.Variables.Delete(c.Context, w.ID, existingVariables[kind][v.Name].ID); err != nil { 219 | return err 220 | } 221 | log.Infof("deleted variable %s", v.Name) 222 | } 223 | } 224 | } 225 | 226 | return 227 | } 228 | 229 | func (c *Client) updateSSHKey(w *tfc.Workspace, sshKeyName string) error { 230 | if sshKeyName == "-" { 231 | log.Infof("Removing currently configured SSH key") 232 | _, err := c.TFC.Workspaces.UnassignSSHKey(c.Context, w.ID) 233 | return err 234 | } 235 | 236 | sshKeys, err := c.TFC.SSHKeys.List(c.Context, w.Organization.Name, tfc.SSHKeyListOptions{}) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | for _, sshKey := range sshKeys.Items { 242 | if sshKey.Name == sshKeyName { 243 | log.Infof("Updating configured SSH key to '%s'", sshKey.Name) 244 | _, err := c.TFC.Workspaces.AssignSSHKey(c.Context, w.ID, tfc.WorkspaceAssignSSHKeyOptions{ 245 | SSHKeyID: &sshKey.ID, 246 | }) 247 | return err 248 | } 249 | } 250 | 251 | return fmt.Errorf("could not find ssh key '%s'", sshKeyName) 252 | } 253 | 254 | func (c *Client) shouldUpdateSSHKey(w *tfc.Workspace, sshKeyName string) (bool, error) { 255 | var sshKey *tfc.SSHKey 256 | var err error 257 | if sshKeyName == "-" && w.SSHKey != nil { 258 | log.Infof("Workspace ssh key should not be configured, we will remove it") 259 | return true, nil 260 | } 261 | 262 | if w.SSHKey == nil { 263 | log.Infof("Workspace ssh key not configured, wanted '%s', we will update", sshKeyName) 264 | return true, nil 265 | } 266 | 267 | sshKey, err = c.TFC.SSHKeys.Read(c.Context, w.SSHKey.ID) 268 | if err != nil { 269 | return false, fmt.Errorf("could not fetch ssh key from API: %v", err) 270 | } 271 | 272 | if sshKeyName != sshKey.Name { 273 | log.Infof("Workspace ssh key configured with '%s', wanted '%s', we will update", sshKey.Name, sshKeyName) 274 | return true, nil 275 | } 276 | 277 | return false, nil 278 | } 279 | -------------------------------------------------------------------------------- /pkg/tfcw/runs.go: -------------------------------------------------------------------------------- 1 | package tfcw 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | tfc "github.com/hashicorp/go-tfe" 13 | "github.com/jpillora/backoff" 14 | "github.com/manifoldco/promptui" 15 | "github.com/mvisonneau/tfcw/pkg/schemas" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | // TFCRunType defines possible TFC run types 20 | type TFCRunType string 21 | 22 | const ( 23 | // TFCRunTypePlan refers to a TFC `plan` 24 | TFCRunTypePlan TFCRunType = "plan" 25 | 26 | // TFCRunTypeApply refers to a TFC `apply` 27 | TFCRunTypeApply TFCRunType = "apply" 28 | ) 29 | 30 | // TFCCreateRunOptions handles configuration variables for creating a new run on TFE 31 | type TFCCreateRunOptions struct { 32 | AutoApprove bool 33 | AutoDiscard bool 34 | NoPrompt bool 35 | OutputPath string 36 | Message string 37 | StartTimeout time.Duration 38 | } 39 | 40 | // CreateRun triggers a `run` over the TFC API 41 | func (c *Client) CreateRun(cfg *schemas.Config, w *tfc.Workspace, opts *TFCCreateRunOptions) error { 42 | log.Info("Preparing plan") 43 | 44 | // If the workspace is not configured with remote runs enabled we return an error 45 | if !w.Operations { 46 | return fmt.Errorf("remote operations must be enabled on the workspace") 47 | } 48 | 49 | configVersion, err := c.createConfigurationVersion(w) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | if err := c.uploadConfigurationVersion(w, configVersion, cfg.Runtime.WorkingDir); err != nil { 55 | return err 56 | } 57 | 58 | run, err := c.createRun(w, configVersion, opts.Message) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if len(opts.OutputPath) > 0 { 64 | log.Debugf("saving run ID on disk at '%s'", opts.OutputPath) 65 | if err = ioutil.WriteFile(opts.OutputPath, []byte(run.ID), 0o600); err != nil { 66 | if err = c.DiscardRun(run.ID, opts.Message); err != nil { 67 | return err 68 | } 69 | return err 70 | } 71 | } 72 | 73 | planID, err := c.getTerraformPlanID(run) 74 | if err != nil { 75 | if err = c.DiscardRun(run.ID, opts.Message); err != nil { 76 | return err 77 | } 78 | return err 79 | } 80 | 81 | plan, err := c.waitForTerraformPlan(planID, opts.StartTimeout) 82 | if err != nil { 83 | if err = c.DiscardRun(run.ID, opts.Message); err != nil { 84 | return err 85 | } 86 | return err 87 | } 88 | 89 | if plan.HasChanges { 90 | // If the workspace is configured with AutoApply=true, we skip the approval 91 | // part and automatically follow the apply logs 92 | if w.AutoApply { 93 | // Refresh run object to fetch the Apply.ID 94 | run, err := c.TFC.Runs.Read(c.Context, run.ID) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return c.waitForTerraformApply(run.Apply.ID) 100 | } 101 | 102 | if opts.AutoDiscard { 103 | return c.DiscardRun(run.ID, opts.Message) 104 | } 105 | 106 | if opts.AutoApprove { 107 | return c.ApproveRun(run.ID, opts.Message) 108 | } 109 | 110 | if opts.NoPrompt { 111 | return nil 112 | } 113 | 114 | if promptApproveRun() { 115 | return c.ApproveRun(run.ID, opts.Message) 116 | } 117 | 118 | return c.DiscardRun(run.ID, opts.Message) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | // ApproveRun given its ID 125 | func (c *Client) ApproveRun(runID, message string) error { 126 | log.Infof("Approving run ID: %s", runID) 127 | if err := c.TFC.Runs.Apply(c.Context, runID, tfc.RunApplyOptions{ 128 | Comment: &message, 129 | }); err != nil { 130 | return err 131 | } 132 | 133 | // Refresh run object to fetch the Apply.ID 134 | run, err := c.TFC.Runs.Read(c.Context, runID) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | return c.waitForTerraformApply(run.Apply.ID) 140 | } 141 | 142 | // DiscardRun given its ID 143 | func (c *Client) DiscardRun(runID, message string) error { 144 | log.Infof("Discarding run ID: %s", runID) 145 | return c.TFC.Runs.Discard(c.Context, runID, tfc.RunDiscardOptions{ 146 | Comment: &message, 147 | }) 148 | } 149 | 150 | func (c *Client) createConfigurationVersion(w *tfc.Workspace) (*tfc.ConfigurationVersion, error) { 151 | log.Debug("Creating configuration version") 152 | configVersion, err := c.TFC.ConfigurationVersions.Create(c.Context, w.ID, tfc.ConfigurationVersionCreateOptions{ 153 | AutoQueueRuns: tfc.Bool(false), 154 | }) 155 | if err != nil { 156 | return nil, fmt.Errorf("error creating TFC configuration version: %s", err) 157 | } 158 | 159 | log.Debugf("Configuration version ID: %s", configVersion.ID) 160 | return configVersion, nil 161 | } 162 | 163 | func (c *Client) uploadConfigurationVersion(w *tfc.Workspace, configVersion *tfc.ConfigurationVersion, uploadPath string) error { 164 | if len(w.WorkingDirectory) > 0 { 165 | absolutePath, err := filepath.Abs(uploadPath) 166 | if err != nil { 167 | return fmt.Errorf("unable to find absolute path for terraform configuration folder %s", err.Error()) 168 | } 169 | uploadPath = strings.Replace(absolutePath, w.WorkingDirectory, "", 1) 170 | log.Debugf("Upload path set to %s", uploadPath) 171 | } 172 | 173 | log.Debug("Uploading configuration version..") 174 | if err := c.TFC.ConfigurationVersions.Upload(c.Context, configVersion.UploadURL, uploadPath); err != nil { 175 | return fmt.Errorf("error uploading configuration version: %s", err) 176 | } 177 | log.Debug("Uploaded configuration version!") 178 | return nil 179 | } 180 | 181 | func (c *Client) createRun(w *tfc.Workspace, configVersion *tfc.ConfigurationVersion, message string) (*tfc.Run, error) { 182 | log.Debugf("Creating run for workspace '%s' / configuration version '%s'", w.ID, configVersion.ID) 183 | run, err := c.TFC.Runs.Create(c.Context, tfc.RunCreateOptions{ 184 | Message: &message, 185 | ConfigurationVersion: configVersion, 186 | Workspace: w, 187 | }) 188 | if err != nil { 189 | return nil, fmt.Errorf("error creating run: %s", err) 190 | } 191 | 192 | log.Debugf("Run ID: %s", run.ID) 193 | return run, nil 194 | } 195 | 196 | func (c *Client) getTerraformPlanID(run *tfc.Run) (string, error) { 197 | var err error 198 | 199 | // Sometimes the plan ID is not immediately available when the run is created 200 | for { 201 | if run.Plan != nil { 202 | break 203 | } 204 | 205 | t := c.Backoff.Duration() 206 | log.Infof("Waiting %s for plan ID to be generated..", t.String()) 207 | time.Sleep(t) 208 | 209 | run, err = c.TFC.Runs.Read(c.Context, run.ID) 210 | if err != nil { 211 | return "", err 212 | } 213 | } 214 | 215 | log.Debugf("Plan ID: %s", run.Plan.ID) 216 | return run.Plan.ID, nil 217 | } 218 | 219 | func (c *Client) waitForTerraformPlan(planID string, startTimeout time.Duration) (plan *tfc.Plan, err error) { 220 | time.Sleep(2 * time.Second) 221 | plan, err = c.TFC.Plans.Read(c.Context, planID) 222 | c.Backoff.Reset() 223 | 224 | wait: 225 | for { 226 | plan, err = c.TFC.Plans.Read(c.Context, planID) 227 | if err != nil { 228 | return 229 | } 230 | 231 | switch plan.Status { 232 | case tfc.PlanCanceled: 233 | return plan, fmt.Errorf("plan has been cancelled") 234 | case tfc.PlanErrored: 235 | break wait 236 | case tfc.PlanFinished: 237 | break wait 238 | case tfc.PlanRunning: 239 | break wait 240 | case tfc.PlanUnreachable: 241 | return plan, fmt.Errorf("plan is unreachable from TFC API") 242 | default: 243 | t := c.Backoff.Duration() 244 | if timeoutExhausted(c.Backoff, startTimeout) { 245 | return nil, fmt.Errorf("timed out waiting for the plan to start, exiting now") 246 | } 247 | log.Infof("Waiting for plan to start, current status: %s, sleeping for %s", plan.Status, t.String()) 248 | time.Sleep(t) 249 | } 250 | } 251 | 252 | logs, err := c.TFC.Plans.Logs(c.Context, planID) 253 | if err != nil { 254 | return 255 | } 256 | 257 | if err = readTerraformLogs(logs); err != nil { 258 | return 259 | } 260 | 261 | plan, err = c.TFC.Plans.Read(c.Context, planID) 262 | if err != nil { 263 | return 264 | } 265 | 266 | if plan.Status != tfc.PlanFinished { 267 | return plan, fmt.Errorf("plan status: %s", plan.Status) 268 | } 269 | 270 | return 271 | } 272 | 273 | func (c *Client) waitForTerraformApply(applyID string) error { 274 | var apply *tfc.Apply 275 | var err error 276 | 277 | // Reset the backoff in case it got incremented somewhere else beforehand 278 | c.Backoff.Reset() 279 | 280 | // Sleep for a second on init 281 | time.Sleep(time.Second) 282 | 283 | wait: 284 | for { 285 | apply, err = c.TFC.Applies.Read(c.Context, applyID) 286 | if err != nil { 287 | return err 288 | } 289 | 290 | switch apply.Status { 291 | case tfc.ApplyCanceled: 292 | return fmt.Errorf("apply has been cancelled") 293 | case tfc.ApplyErrored: 294 | break wait 295 | case tfc.ApplyFinished: 296 | break wait 297 | case tfc.ApplyRunning: 298 | break wait 299 | case tfc.ApplyUnreachable: 300 | return fmt.Errorf("apply is unreachable from TFC API") 301 | default: 302 | t := c.Backoff.Duration() 303 | log.Infof("Waiting for apply to start, current status: %s, sleeping for %s", apply.Status, t.String()) 304 | time.Sleep(t) 305 | } 306 | } 307 | 308 | logs, err := c.TFC.Applies.Logs(c.Context, applyID) 309 | if err != nil { 310 | return err 311 | } 312 | 313 | if err = readTerraformLogs(logs); err != nil { 314 | return err 315 | } 316 | 317 | apply, err = c.TFC.Applies.Read(c.Context, applyID) 318 | if err != nil { 319 | return err 320 | } 321 | 322 | if apply.Status != tfc.ApplyFinished { 323 | return fmt.Errorf("apply status: %s", apply.Status) 324 | } 325 | 326 | return nil 327 | } 328 | 329 | func readTerraformLogs(l io.Reader) error { 330 | r := bufio.NewReader(l) 331 | 332 | for { 333 | line, err := r.ReadString('\n') 334 | if err != nil { 335 | if err == io.EOF { 336 | break 337 | } 338 | return err 339 | } 340 | fmt.Print(line) 341 | } 342 | return nil 343 | } 344 | 345 | func promptApproveRun() bool { 346 | prompt := promptui.Prompt{ 347 | Label: "Apply", 348 | IsConfirm: true, 349 | } 350 | 351 | if _, err := prompt.Run(); err != nil { 352 | return false 353 | } 354 | 355 | return true 356 | } 357 | 358 | func saveRunID(runID, outputFile string) { 359 | if len(outputFile) > 0 { 360 | log.Debugf("Saving run ID '%s' onto file %s.", runID, outputFile) 361 | } else { 362 | log.Debugf("Output file not defined, not saving run ID on disk.") 363 | } 364 | } 365 | 366 | func timeoutExhausted(b *backoff.Backoff, t time.Duration) bool { 367 | if t == 0 { 368 | return false 369 | } 370 | 371 | var totalDuration time.Duration 372 | a := float64(0) 373 | for a < b.Attempt() { 374 | totalDuration += b.ForAttempt(a) 375 | if totalDuration >= t { 376 | return true 377 | } 378 | a++ 379 | } 380 | return false 381 | } 382 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright 2020 - Maxime VISONNEAU 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | https://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /docs/configuration_syntax.md: -------------------------------------------------------------------------------- 1 | # TFCW - Configuration syntax 2 | 3 | ## Synopsis 4 | 5 | - [Minimal configuration](#minimal-configuration) 6 | - [Block types](#block-types) 7 | - [tfc](#tfc) 8 | - [defaults](#defaults) 9 | - [tfvar](#tfvar) 10 | - [envvar](#envvar) 11 | - [Provider specific block types](#provider-block-types) 12 | - [vault](#vault) 13 | - [s5](#s5) 14 | - [env](#env) 15 | - [Functions](#functions) 16 | 17 | ## Minimal configuration 18 | 19 | ```hcl 20 | tfc { 21 | // Your organization name in Terraform Cloud 22 | organization = "foo" 23 | 24 | // A workspace block with the name of your workspace 25 | workspace { 26 | name = "bar" 27 | } 28 | } 29 | ``` 30 | 31 | ## Block types 32 | 33 | There are **4 block types** supported by TFCW: 34 | 35 | |**name**|**description**|**required**|**unique**| 36 | |---|---|---|---| 37 | |[tfc](#tfc)|configuration related to TFC and the workspace|`no`|`yes`| 38 | |[defaults](#defaults)|a block containing some default configuration for the variable providers|`no`|`yes`| 39 | |[tfvar](#tfvar)|defines a [Terraform](https://www.terraform.io/docs/cloud/workspaces/variables.html#terraform-variables) variable in TFC|`no`|`no`| 40 | |[envvar](#envvar)|defines an [Environment](https://www.terraform.io/docs/cloud/workspaces/variables.html#environment-variables) variable in TFC|`no`|`no`| 41 | 42 | [tfvar](#tfvar) and [envvar](#envvar) share exactly the same capabilities. They only differ in the sense of types of variables they provider on the TFC API. 43 | 44 | ### tfc 45 | 46 | `tfc` is an optional block that defines the configuration of your workspace. If not set, some values must be defined either through CLI flags or as [Terraform Remote backend configuration](https://www.terraform.io/docs/backends/types/remote.html#example-configurations-and-references) 47 | 48 | ```hcl 49 | tfc { 50 | // Address of the TFC API (required), it can also be defined through: 51 | // the `--address` flag 52 | // the `TFCW_ADDRESS` environment variable 53 | // the `hostname` value of the Terraform remote backend configuration 54 | address = "https://app.terraform.io" 55 | 56 | // Token to authenticate against the TFC API (required), it can also be defined through: 57 | // the `--token` flag 58 | // the `TFCW_TOKEN` environment variable 59 | // the `token` value of the Terraform remote backend configuration 60 | token = "" 61 | 62 | // Name of your organization on TFC (required), it can also be defined through: 63 | // the `--organization` flag 64 | // the `TFCW_ORGANIZATION` environment variable 65 | // the `organization` value of the Terraform remote backend configuration 66 | organization = "acme" 67 | 68 | // Workspace related configuration block (optional) 69 | workspace { 70 | // Name of the workspace of your Terraform stack on TFC (required), it can also be defined through: 71 | // the `--workspace` flag 72 | // the `TFCW_WORKSPACE` environment variable 73 | // the `workspace.name` value of the Terraform remote backend configuration 74 | name = "foo" 75 | 76 | // Whether to run terraform remotely or locally (optional, default: true (remotely)) 77 | operations = true 78 | 79 | // Configure the workspace with the auto-apply flag (optional, default: ) 80 | auto-apply = false 81 | 82 | // Configure the workspace terraform version (optional, default: ) 83 | terraform-version = "0.12.24" 84 | 85 | // Configure the workspace working directory (optional, default: ) 86 | working-directory = "/foo" 87 | 88 | // Name of the SSH key to use (optional, default: ) 89 | ssh-key = "bar" 90 | } 91 | 92 | // This flag enables the creating of the workspace if TFCW cannot find it under 93 | // the organization (optional, default: true) 94 | workspace-auto-create = true 95 | 96 | // Whether to purge or leave the workspace variables which are 97 | // not configured within this file (optional, default: false) 98 | purge-unmanaged-variables = false 99 | } 100 | ``` 101 | 102 | Here is a contextualized example: [docs/examples/workspace_configuration.md](examples/workspace_configuration.md) 103 | 104 | ### defaults 105 | 106 | `defaults` is an optional block that allows you to define default configuration for the variable providers you are planning on using. 107 | 108 | ```hcl 109 | defaults { 110 | 111 | // Set some default values for variables 112 | var { 113 | // Whether to declare this variable sensitive in TFC (optional, default: true) 114 | // More information: https://www.terraform.io/docs/cloud/workspaces/variables.html#sensitive-values 115 | sensitive = true 116 | 117 | // Whether to interprete this variable content as HCL in TFC (optional, default: false) 118 | // More information: https://www.terraform.io/docs/cloud/workspaces/variables.html#hcl-values 119 | hcl = false 120 | 121 | // TFCW will update the variable once this duration has been exceeded since the 122 | // last update (optional, default: -> always refresh value) 123 | // Format must comply with golang time.ParseDuration() function: 124 | // https://golang.org/pkg/time/#ParseDuration 125 | ttl = "1h" 126 | } 127 | 128 | // You can define as many provider blocks as you want 129 | // Default Vault configuration 130 | vault { 131 | ... 132 | } 133 | 134 | // Default S5 configuration 135 | s5 { 136 | ... 137 | } 138 | 139 | // There is no default configuration support for the env provider though 140 | } 141 | ``` 142 | 143 | ### tfvar 144 | 145 | `tfvar` defines a [Terraform](https://www.terraform.io/docs/cloud/workspaces/variables.html#terraform-variables) variable in TFC. You can only use **one** provider block in each `tfvar` block. 146 | 147 | ```hcl 148 | tfvar "" { 149 | // Name can be used to override the label of the resource (optional, default: