backend/consul: support named states

This commit is contained in:
Mitchell Hashimoto 2017-03-01 22:58:51 -08:00
parent 3db55cf747
commit b842fd0c27
No known key found for this signature in database
GPG Key ID: 744E147AA52F5B0A
3 changed files with 265 additions and 10 deletions

View File

@ -1,24 +1,80 @@
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) {
return nil, backend.ErrNamedStatesNotSupported
// 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 {
return backend.ErrNamedStatesNotSupported
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
_, err = client.KV().Delete(path, nil)
return err
}
func (b *Backend) State(name string) (state.State, error) {
if name != backend.DefaultStateName {
return nil, backend.ErrNamedStatesNotSupported
}
// Get the Consul API client
client, err := b.clientRaw()
if err != nil {
@ -26,13 +82,76 @@ func (b *Backend) State(name string) (state.State, error) {
}
// Determine the path of the data
path := b.configData.Get("path").(string)
path := b.path(name)
// Build the remote state client
return &remote.State{
// Build the state client
stateMgr := &remote.State{
Client: &RemoteClient{
Client: client,
Path: path,
},
}, nil
}
// 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.
`

View File

@ -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)
}

View File

@ -1,6 +1,8 @@
package backend
import (
"reflect"
"sort"
"testing"
"github.com/hashicorp/terraform/config"
@ -33,3 +35,106 @@ func TestBackendConfig(t *testing.T, b Backend, c map[string]interface{}) Backen
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)
}
}
}