From b842fd0c2781b225f21a6603efb37bc2b86c66eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 1 Mar 2017 22:58:51 -0800 Subject: [PATCH] backend/consul: support named states --- backend/remote-state/consul/backend_state.go | 139 +++++++++++++++++-- backend/remote-state/consul/backend_test.go | 31 +++++ backend/testing.go | 105 ++++++++++++++ 3 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 backend/remote-state/consul/backend_test.go diff --git a/backend/remote-state/consul/backend_state.go b/backend/remote-state/consul/backend_state.go index ac8fa4091..d64a8955d 100644 --- a/backend/remote-state/consul/backend_state.go +++ b/backend/remote-state/consul/backend_state.go @@ -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. +` diff --git a/backend/remote-state/consul/backend_test.go b/backend/remote-state/consul/backend_test.go new file mode 100644 index 000000000..fb2c0e0f6 --- /dev/null +++ b/backend/remote-state/consul/backend_test.go @@ -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) +} diff --git a/backend/testing.go b/backend/testing.go index 09c80fd18..5298131cf 100644 --- a/backend/testing.go +++ b/backend/testing.go @@ -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) + } + } +}