Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a cmd type remote-state #22799

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
backendRemote "github.com/hashicorp/terraform/backend/remote"
backendArtifactory "github.com/hashicorp/terraform/backend/remote-state/artifactory"
backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure"
backendCmd "github.com/hashicorp/terraform/backend/remote-state/cmd"
backendConsul "github.com/hashicorp/terraform/backend/remote-state/consul"
backendEtcdv2 "github.com/hashicorp/terraform/backend/remote-state/etcdv2"
backendEtcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
Expand Down Expand Up @@ -67,6 +68,7 @@ func Init(services *disco.Disco) {
"pg": func() backend.Backend { return backendPg.New() },
"s3": func() backend.Backend { return backendS3.New() },
"swift": func() backend.Backend { return backendSwift.New() },
"cmd": func() backend.Backend { return backendCmd.New() },

// Deprecated backends.
"azure": func() backend.Backend {
Expand Down
73 changes: 73 additions & 0 deletions backend/remote-state/cmd/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"context"

"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/state"
"github.com/hashicorp/terraform/state/remote"
)

func New() backend.Backend {
s := &schema.Backend{
Schema: map[string]*schema.Schema{
"base_command": &schema.Schema{
Type: schema.TypeString,
Required: true,
Description: "base command, to be called with one of PUT, GET, DELETE, LOCK, or UNLOCK as the only argument",
},
"state_transfer_file": &schema.Schema{
Type: schema.TypeString,
Required: true,
Description: "path to the file that passes state between terraform and the base_command",
},
"lock_transfer_file": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "path to the file that passes lock between terraform and the base_command",
},
},
}

b := &Backend{Backend: s}
b.Backend.ConfigureFunc = b.configure
return b
}

type Backend struct {
*schema.Backend
client *CmdClient
}

func (b *Backend) configure(ctx context.Context) error {
data := schema.FromContextBackendConfig(ctx)

baseCmd := data.Get("base_command").(string)
statesTransferFile := data.Get("state_transfer_file").(string)
lockTransferFile := data.Get("lock_transfer_file").(string)

b.client = &CmdClient{
baseCmd: baseCmd,
statesTransferFile: statesTransferFile,
lockTransferFile: lockTransferFile,
}
return nil
}

func (b *Backend) Workspaces() ([]string, error) {
return nil, backend.ErrWorkspacesNotSupported
}

func (b *Backend) DeleteWorkspace(string) error {
return backend.ErrWorkspacesNotSupported
}

func (b *Backend) StateMgr(name string) (state.State, error) {
if name != backend.DefaultStateName {
return nil, backend.ErrWorkspacesNotSupported
}
return &remote.State{
Client: b.client,
}, nil
}
132 changes: 132 additions & 0 deletions backend/remote-state/cmd/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cmd

import (
"bytes"
"crypto/md5"
"io/ioutil"
"log"
"os"
"os/exec"

"github.com/hashicorp/terraform/state"
"github.com/hashicorp/terraform/state/remote"
)

type CmdClient struct {
baseCmd string
statesTransferFile string
lockTransferFile string
lockID string
jsonLockInfo []byte
}

func (c *CmdClient) execCommand(arg string) error {
args := []string{arg}
cmd := exec.Command(c.baseCmd, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
log.Printf("[TRACE] backend/remote-state/cmd execCommand: %s\n%s", arg, out.String())
return err
}

func logStart(action string) {
log.Printf("[TRACE] backend/remote-state/cmd: starting %s operation", action)
}

func logResult(action string, err *error) {
if *err == nil {
log.Printf("[TRACE] backend/remote-state/cmd: exiting %s operation with success", action)
} else {
log.Printf("[TRACE] backend/remote-state/cmd: exiting %s operation with failure", action)
}
}

func (c *CmdClient) Get() (*remote.Payload, error) {
var err error
logStart("Get")
defer logResult("Get", &err)
if err = c.execCommand("GET"); err != nil {
return nil, err
}

file, err := os.Open(c.statesTransferFile)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
output, err := ioutil.ReadAll(file)

hash := md5.Sum(output)
payload := &remote.Payload{
Data: output,
MD5: hash[:md5.Size],
}

// If there was no data, then return nil
if len(payload.Data) == 0 {
return nil, nil
}

return payload, nil
}

func (c *CmdClient) Put(data []byte) error {
var err error
logStart("Put")
defer logResult("Put", &err)
err = ioutil.WriteFile(c.statesTransferFile, data, 0644)
if err != nil {
return err
}
err = c.execCommand("PUT")
return err
}

func (c *CmdClient) Delete() error {
var err error
logStart("Delete")
defer logResult("Delete", &err)
err = c.execCommand("DELETE")
return err
}

func (c *CmdClient) Unlock(id string) error {
var err error
logStart("Unlock")
defer logResult("Unlock", &err)
if c.lockTransferFile == "" {
return nil
}
err = c.execCommand("UNLOCK")
return err
}

func (c *CmdClient) Lock(info *state.LockInfo) (string, error) {
var err error
logStart("Lock")
defer logResult("Lock", &err)
if c.lockTransferFile == "" {
return "", nil
}
c.lockID = ""

jsonLockInfo := info.Marshal()
err = ioutil.WriteFile(c.lockTransferFile, jsonLockInfo, 0644)
if err != nil {
return "", err
}

err = c.execCommand("LOCK")
if err != nil {
return "", err
} else {
c.lockID = info.ID
c.jsonLockInfo = jsonLockInfo
return info.ID, nil
}
}
44 changes: 44 additions & 0 deletions backend/remote-state/cmd/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"testing"

"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/configs"
"github.com/hashicorp/terraform/state/remote"
"github.com/zclconf/go-cty/cty"
)

func TestCmdClient_impl(t *testing.T) {
var _ remote.Client = new(CmdClient)
}

func TestCmdFactory(t *testing.T) {
// This test just instantiates the client. Shouldn't make any actual
// requests nor incur any costs.

config := make(map[string]cty.Value)

config["base_command"] = cty.StringVal("/usr/bin/base_command")
config["state_transfer_file"] = cty.StringVal("terraform_states_file")
config["lock_transfer_file"] = cty.StringVal("terraform_lock_file")

b := backend.TestBackendConfig(t, New(), configs.SynthBody("synth", config))

state, err := b.StateMgr(backend.DefaultStateName)
if err != nil {
t.Fatalf("Error for valid config: %s", err)
}

cmdClient := state.(*remote.State).Client.(*CmdClient)

if cmdClient.baseCmd != "/usr/bin/base_command" {
t.Fatalf("Incorrect base_command was populated")
}
if cmdClient.statesTransferFile != "terraform_states_file" {
t.Fatalf("Incorrect state_transfer_file was populated")
}
if cmdClient.lockTransferFile != "terraform_lock_file" {
t.Fatalf("Incorrect lock_transfer_file was populated")
}
}
55 changes: 55 additions & 0 deletions website/docs/backends/types/cmd.html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
layout: "backend-types"
page_title: "Backend Type: cmd"
sidebar_current: "docs-backends-types-standard-cmd"
description: |-
Terraform can delegate state storage and locking to an external command.
---

# cmd

**Kind: Standard (with optional locking)**

Delegates the storage and locking of state to an external command.
Uses files to pass the content of state and lock between terraform and the command.
Calls the external command with one of these subcommands: 'GET', 'PUT', 'DELETE', 'LOCK', 'UNLOCK'.
* GET: retrieve the state from storage and save its content to `state_transfer_file`
* PUT: read content of the state from `state_transfer_file` and save the state to storage
* DELETE: delete the state from storage
* LOCK (optional): read content of the lock from `lock_transfer_file` and create a lock with the content
* UNLOCK (optional): remove the lock

```hcl
terraform {
backend "cmd" {
base_command = "./backend.sh"
state_transfer_file = "state_transfer"
lock_transfer_file = "lock_transfer"
}
}
```

## Example Referencing

```hcl
data "terraform_remote_state" "foo" {
backend = "cmd"
config = {
base_command = "./backend.sh"
state_transfer_file = "state_transfer"
lock_transfer_file = "lock_transfer"
}
}
```

## Configuration variables

The following configuration options / environment variables are supported:

* `base_command` (Required) - Pass to the external command
* `state_transfer_file` (Required) - Path to the intermediate file for state
* `lock_transfer_file` (Optional) - Path to the intermediate file for lock

## Sample external command

[Sample implementation of external command](https://github.com/bzcnsh/tf_remote_state_cmd_samples)
3 changes: 3 additions & 0 deletions website/layouts/backend-types.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
<li<%= sidebar_current("docs-backends-types-standard-azurerm") %>>
<a href="/docs/backends/types/azurerm.html">azurerm</a>
</li>
<li<%= sidebar_current("docs-backends-types-standard-cmd") %>>
<a href="/docs/backends/types/cmd.html">cmd</a>
</li>
<li<%= sidebar_current("docs-backends-types-standard-consul") %>>
<a href="/docs/backends/types/consul.html">consul</a>
</li>
Expand Down