Merge pull request #12367 from hashicorp/f-consul-state
backend/consul: support named states
This commit is contained in:
commit
45ca69e09f
|
@ -6,67 +6,78 @@ import (
|
||||||
|
|
||||||
consulapi "github.com/hashicorp/consul/api"
|
consulapi "github.com/hashicorp/consul/api"
|
||||||
"github.com/hashicorp/terraform/backend"
|
"github.com/hashicorp/terraform/backend"
|
||||||
"github.com/hashicorp/terraform/backend/remote-state"
|
|
||||||
"github.com/hashicorp/terraform/helper/schema"
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
"github.com/hashicorp/terraform/state/remote"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// New creates a new backend for Consul remote state.
|
// New creates a new backend for Consul remote state.
|
||||||
func New() backend.Backend {
|
func New() backend.Backend {
|
||||||
return &remotestate.Backend{
|
s := &schema.Backend{
|
||||||
ConfigureFunc: configure,
|
Schema: map[string]*schema.Schema{
|
||||||
|
"path": &schema.Schema{
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Path to store state in Consul",
|
||||||
|
},
|
||||||
|
|
||||||
// Set the schema
|
"access_token": &schema.Schema{
|
||||||
Backend: &schema.Backend{
|
Type: schema.TypeString,
|
||||||
Schema: map[string]*schema.Schema{
|
Optional: true,
|
||||||
"path": &schema.Schema{
|
Description: "Access token for a Consul ACL",
|
||||||
Type: schema.TypeString,
|
Default: "", // To prevent input
|
||||||
Required: true,
|
},
|
||||||
Description: "Path to store state in Consul",
|
|
||||||
},
|
|
||||||
|
|
||||||
"access_token": &schema.Schema{
|
"address": &schema.Schema{
|
||||||
Type: schema.TypeString,
|
Type: schema.TypeString,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Description: "Access token for a Consul ACL",
|
Description: "Address to the Consul Cluster",
|
||||||
Default: "", // To prevent input
|
Default: "", // To prevent input
|
||||||
},
|
},
|
||||||
|
|
||||||
"address": &schema.Schema{
|
"scheme": &schema.Schema{
|
||||||
Type: schema.TypeString,
|
Type: schema.TypeString,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Description: "Address to the Consul Cluster",
|
Description: "Scheme to communicate to Consul with",
|
||||||
Default: "", // To prevent input
|
Default: "", // To prevent input
|
||||||
},
|
},
|
||||||
|
|
||||||
"scheme": &schema.Schema{
|
"datacenter": &schema.Schema{
|
||||||
Type: schema.TypeString,
|
Type: schema.TypeString,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Description: "Scheme to communicate to Consul with",
|
Description: "Datacenter to communicate with",
|
||||||
Default: "", // To prevent input
|
Default: "", // To prevent input
|
||||||
},
|
},
|
||||||
|
|
||||||
"datacenter": &schema.Schema{
|
"http_auth": &schema.Schema{
|
||||||
Type: schema.TypeString,
|
Type: schema.TypeString,
|
||||||
Optional: true,
|
Optional: true,
|
||||||
Description: "Datacenter to communicate with",
|
Description: "HTTP Auth in the format of 'username:password'",
|
||||||
Default: "", // To prevent input
|
Default: "", // To prevent input
|
||||||
},
|
|
||||||
|
|
||||||
"http_auth": &schema.Schema{
|
|
||||||
Type: schema.TypeString,
|
|
||||||
Optional: true,
|
|
||||||
Description: "HTTP Auth in the format of 'username:password'",
|
|
||||||
Default: "", // To prevent input
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result := &Backend{Backend: s}
|
||||||
|
result.Backend.ConfigureFunc = result.configure
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(ctx context.Context) (remote.Client, error) {
|
type Backend struct {
|
||||||
|
*schema.Backend
|
||||||
|
|
||||||
|
configData *schema.ResourceData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) configure(ctx context.Context) error {
|
||||||
// Grab the resource data
|
// Grab the resource data
|
||||||
data := schema.FromContextBackendConfig(ctx)
|
b.configData = schema.FromContextBackendConfig(ctx)
|
||||||
|
|
||||||
|
// Initialize a client to test config
|
||||||
|
_, err := b.clientRaw()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) clientRaw() (*consulapi.Client, error) {
|
||||||
|
data := b.configData
|
||||||
|
|
||||||
// Configure the client
|
// Configure the client
|
||||||
config := consulapi.DefaultConfig()
|
config := consulapi.DefaultConfig()
|
||||||
|
@ -100,13 +111,5 @@ func configure(ctx context.Context) (remote.Client, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := consulapi.NewClient(config)
|
return consulapi.NewClient(config)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &RemoteClient{
|
|
||||||
Client: client,
|
|
||||||
Path: data.Get("path").(string),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,158 @@
|
||||||
|
package consul
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/backend"
|
||||||
|
"github.com/hashicorp/terraform/state"
|
||||||
|
"github.com/hashicorp/terraform/state/remote"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyEnvPrefix = "-env:"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) States() ([]string, error) {
|
||||||
|
// Get the Consul client
|
||||||
|
client, err := b.clientRaw()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List our raw path
|
||||||
|
prefix := b.configData.Get("path").(string) + keyEnvPrefix
|
||||||
|
keys, _, err := client.KV().Keys(prefix, "/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the envs, we use a map since we can get duplicates with
|
||||||
|
// path suffixes.
|
||||||
|
envs := map[string]struct{}{}
|
||||||
|
for _, key := range keys {
|
||||||
|
// Consul should ensure this but it doesn't hurt to check again
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
key = strings.TrimPrefix(key, prefix)
|
||||||
|
|
||||||
|
// Ignore anything with a "/" in it since we store the state
|
||||||
|
// directly in a key not a directory.
|
||||||
|
if idx := strings.IndexRune(key, '/'); idx >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
envs[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 1, len(envs)+1)
|
||||||
|
result[0] = backend.DefaultStateName
|
||||||
|
for k, _ := range envs {
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) DeleteState(name string) error {
|
||||||
|
if name == backend.DefaultStateName {
|
||||||
|
return fmt.Errorf("can't delete default state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Consul API client
|
||||||
|
client, err := b.clientRaw()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the path of the data
|
||||||
|
path := b.path(name)
|
||||||
|
|
||||||
|
// Delete it. We just delete it without any locking since
|
||||||
|
// the DeleteState API is documented as such.
|
||||||
|
_, err = client.KV().Delete(path, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) State(name string) (state.State, error) {
|
||||||
|
// Get the Consul API client
|
||||||
|
client, err := b.clientRaw()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the path of the data
|
||||||
|
path := b.path(name)
|
||||||
|
|
||||||
|
// Build the state client
|
||||||
|
stateMgr := &remote.State{
|
||||||
|
Client: &RemoteClient{
|
||||||
|
Client: client,
|
||||||
|
Path: path,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab a lock, we use this to write an empty state if one doesn't
|
||||||
|
// exist already. We have to write an empty state as a sentinel value
|
||||||
|
// so States() knows it exists.
|
||||||
|
lockInfo := state.NewLockInfo()
|
||||||
|
lockInfo.Operation = "init"
|
||||||
|
lockId, err := stateMgr.Lock(lockInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to lock state in Consul: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local helper function so we can call it multiple places
|
||||||
|
lockUnlock := func(parent error) error {
|
||||||
|
if err := stateMgr.Unlock(lockId); err != nil {
|
||||||
|
return fmt.Errorf(strings.TrimSpace(errStateUnlock), lockId, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab the value
|
||||||
|
if err := stateMgr.RefreshState(); err != nil {
|
||||||
|
err = lockUnlock(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no state, we have to create an empty state
|
||||||
|
if v := stateMgr.State(); v == nil {
|
||||||
|
if err := stateMgr.WriteState(terraform.NewState()); err != nil {
|
||||||
|
err = lockUnlock(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := stateMgr.PersistState(); err != nil {
|
||||||
|
err = lockUnlock(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock, the state should now be initialized
|
||||||
|
if err := lockUnlock(nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stateMgr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) path(name string) string {
|
||||||
|
path := b.configData.Get("path").(string)
|
||||||
|
if name != backend.DefaultStateName {
|
||||||
|
path += fmt.Sprintf("%s%s", keyEnvPrefix, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const errStateUnlock = `
|
||||||
|
Error unlocking Consul state. Lock ID: %s
|
||||||
|
|
||||||
|
Error: %s
|
||||||
|
|
||||||
|
You may have to force-unlock this state in order to use it again.
|
||||||
|
The Consul backend acquires a lock during initialization to ensure
|
||||||
|
the minimum required key/values are prepared.
|
||||||
|
`
|
|
@ -0,0 +1,31 @@
|
||||||
|
package consul
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/backend"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackend_impl(t *testing.T) {
|
||||||
|
var _ backend.Backend = new(Backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackend(t *testing.T) {
|
||||||
|
addr := os.Getenv("CONSUL_HTTP_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
t.Log("consul tests require CONSUL_HTTP_ADDR")
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the backend
|
||||||
|
b := backend.TestBackendConfig(t, New(), map[string]interface{}{
|
||||||
|
"address": addr,
|
||||||
|
"path": fmt.Sprintf("tf-unit/%s", time.Now().String()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test
|
||||||
|
backend.TestBackend(t, b)
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/backend"
|
"github.com/hashicorp/terraform/backend"
|
||||||
"github.com/hashicorp/terraform/backend/remote-state"
|
|
||||||
"github.com/hashicorp/terraform/state/remote"
|
"github.com/hashicorp/terraform/state/remote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,8 +28,14 @@ func TestRemoteClient(t *testing.T) {
|
||||||
"path": fmt.Sprintf("tf-unit/%s", time.Now().String()),
|
"path": fmt.Sprintf("tf-unit/%s", time.Now().String()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Grab the client
|
||||||
|
state, err := b.State(backend.DefaultStateName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
remotestate.TestClient(t, b)
|
remote.TestClient(t, state.(*remote.State).Client)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsul_stateLock(t *testing.T) {
|
func TestConsul_stateLock(t *testing.T) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/config"
|
"github.com/hashicorp/terraform/config"
|
||||||
|
@ -33,3 +35,106 @@ func TestBackendConfig(t *testing.T, b Backend, c map[string]interface{}) Backen
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBackend will test the functionality of a Backend. The backend is
|
||||||
|
// assumed to already be configured. This will test state functionality.
|
||||||
|
// If the backend reports it doesn't support multi-state by returning the
|
||||||
|
// error ErrNamedStatesNotSupported, then it will not test that.
|
||||||
|
func TestBackend(t *testing.T, b Backend) {
|
||||||
|
testBackendStates(t, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackendStates(t *testing.T, b Backend) {
|
||||||
|
states, err := b.States()
|
||||||
|
if err == ErrNamedStatesNotSupported {
|
||||||
|
t.Logf("TestBackend: named states not supported in %T, skipping", b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test it starts with only the default
|
||||||
|
if len(states) != 1 || states[0] != DefaultStateName {
|
||||||
|
t.Fatalf("should only have default to start: %#v", states)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a couple states
|
||||||
|
fooState, err := b.State("foo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %s", err)
|
||||||
|
}
|
||||||
|
if err := fooState.RefreshState(); err != nil {
|
||||||
|
t.Fatalf("bad: %s", err)
|
||||||
|
}
|
||||||
|
if v := fooState.State(); v.HasResources() {
|
||||||
|
t.Fatalf("should be empty: %s", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
barState, err := b.State("bar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error: %s", err)
|
||||||
|
}
|
||||||
|
if err := barState.RefreshState(); err != nil {
|
||||||
|
t.Fatalf("bad: %s", err)
|
||||||
|
}
|
||||||
|
if v := barState.State(); v.HasResources() {
|
||||||
|
t.Fatalf("should be empty: %s", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify they are distinct states
|
||||||
|
{
|
||||||
|
s := barState.State()
|
||||||
|
s.Lineage = "bar"
|
||||||
|
if err := barState.WriteState(s); err != nil {
|
||||||
|
t.Fatalf("bad: %s", err)
|
||||||
|
}
|
||||||
|
if err := barState.PersistState(); err != nil {
|
||||||
|
t.Fatalf("bad: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fooState.RefreshState(); err != nil {
|
||||||
|
t.Fatalf("bad: %s", err)
|
||||||
|
}
|
||||||
|
if v := fooState.State(); v.Lineage == "bar" {
|
||||||
|
t.Fatalf("bad: %#v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we can now list them
|
||||||
|
{
|
||||||
|
states, err := b.States()
|
||||||
|
if err == ErrNamedStatesNotSupported {
|
||||||
|
t.Logf("TestBackend: named states not supported in %T, skipping", b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(states)
|
||||||
|
expected := []string{"bar", "default", "foo"}
|
||||||
|
if !reflect.DeepEqual(states, expected) {
|
||||||
|
t.Fatalf("bad: %#v", states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete some states
|
||||||
|
if err := b.DeleteState("foo"); err != nil {
|
||||||
|
t.Fatalf("err: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the default state can't be deleted
|
||||||
|
if err := b.DeleteState(DefaultStateName); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
{
|
||||||
|
states, err := b.States()
|
||||||
|
if err == ErrNamedStatesNotSupported {
|
||||||
|
t.Logf("TestBackend: named states not supported in %T, skipping", b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(states)
|
||||||
|
expected := []string{"bar", "default"}
|
||||||
|
if !reflect.DeepEqual(states, expected) {
|
||||||
|
t.Fatalf("bad: %#v", states)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue