From 3db55cf747eb07656e4d7860c5330894e366f40f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 1 Mar 2017 22:15:08 -0800 Subject: [PATCH 1/3] backend/consul: build your own backend --- backend/remote-state/consul/backend.go | 111 ++++++++++--------- backend/remote-state/consul/backend_state.go | 38 +++++++ backend/remote-state/consul/client_test.go | 9 +- 3 files changed, 102 insertions(+), 56 deletions(-) create mode 100644 backend/remote-state/consul/backend_state.go diff --git a/backend/remote-state/consul/backend.go b/backend/remote-state/consul/backend.go index 1a5a93c64..7193bf8a9 100644 --- a/backend/remote-state/consul/backend.go +++ b/backend/remote-state/consul/backend.go @@ -6,67 +6,78 @@ import ( consulapi "github.com/hashicorp/consul/api" "github.com/hashicorp/terraform/backend" - "github.com/hashicorp/terraform/backend/remote-state" "github.com/hashicorp/terraform/helper/schema" - "github.com/hashicorp/terraform/state/remote" ) // New creates a new backend for Consul remote state. func New() backend.Backend { - return &remotestate.Backend{ - ConfigureFunc: configure, + s := &schema.Backend{ + Schema: map[string]*schema.Schema{ + "path": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: "Path to store state in Consul", + }, - // Set the schema - Backend: &schema.Backend{ - Schema: map[string]*schema.Schema{ - "path": &schema.Schema{ - Type: schema.TypeString, - Required: true, - Description: "Path to store state in Consul", - }, + "access_token": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "Access token for a Consul ACL", + Default: "", // To prevent input + }, - "access_token": &schema.Schema{ - Type: schema.TypeString, - Optional: true, - Description: "Access token for a Consul ACL", - Default: "", // To prevent input - }, + "address": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "Address to the Consul Cluster", + Default: "", // To prevent input + }, - "address": &schema.Schema{ - Type: schema.TypeString, - Optional: true, - Description: "Address to the Consul Cluster", - Default: "", // To prevent input - }, + "scheme": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "Scheme to communicate to Consul with", + Default: "", // To prevent input + }, - "scheme": &schema.Schema{ - Type: schema.TypeString, - Optional: true, - Description: "Scheme to communicate to Consul with", - Default: "", // To prevent input - }, + "datacenter": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Description: "Datacenter to communicate with", + Default: "", // To prevent input + }, - "datacenter": &schema.Schema{ - Type: schema.TypeString, - Optional: true, - Description: "Datacenter to communicate with", - 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 - }, + "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 - 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 config := consulapi.DefaultConfig() @@ -100,13 +111,5 @@ func configure(ctx context.Context) (remote.Client, error) { } } - client, err := consulapi.NewClient(config) - if err != nil { - return nil, err - } - - return &RemoteClient{ - Client: client, - Path: data.Get("path").(string), - }, nil + return consulapi.NewClient(config) } diff --git a/backend/remote-state/consul/backend_state.go b/backend/remote-state/consul/backend_state.go new file mode 100644 index 000000000..ac8fa4091 --- /dev/null +++ b/backend/remote-state/consul/backend_state.go @@ -0,0 +1,38 @@ +package consul + +import ( + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/state/remote" +) + +func (b *Backend) States() ([]string, error) { + return nil, backend.ErrNamedStatesNotSupported +} + +func (b *Backend) DeleteState(name string) error { + return backend.ErrNamedStatesNotSupported +} + +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 { + return nil, err + } + + // Determine the path of the data + path := b.configData.Get("path").(string) + + // Build the remote state client + return &remote.State{ + Client: &RemoteClient{ + Client: client, + Path: path, + }, + }, nil +} diff --git a/backend/remote-state/consul/client_test.go b/backend/remote-state/consul/client_test.go index 2cb11f0d9..d123e39c7 100644 --- a/backend/remote-state/consul/client_test.go +++ b/backend/remote-state/consul/client_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/hashicorp/terraform/backend" - "github.com/hashicorp/terraform/backend/remote-state" "github.com/hashicorp/terraform/state/remote" ) @@ -29,8 +28,14 @@ func TestRemoteClient(t *testing.T) { "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 - remotestate.TestClient(t, b) + remote.TestClient(t, state.(*remote.State).Client) } func TestConsul_stateLock(t *testing.T) { From b842fd0c2781b225f21a6603efb37bc2b86c66eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 1 Mar 2017 22:58:51 -0800 Subject: [PATCH 2/3] 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) + } + } +} From 35afb7bc86078500a891bde3fcc74da0e667fb50 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 1 Mar 2017 23:01:28 -0800 Subject: [PATCH 3/3] backend/consul: some comments --- backend/remote-state/consul/backend_state.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/remote-state/consul/backend_state.go b/backend/remote-state/consul/backend_state.go index d64a8955d..6e6d115f0 100644 --- a/backend/remote-state/consul/backend_state.go +++ b/backend/remote-state/consul/backend_state.go @@ -69,7 +69,8 @@ func (b *Backend) DeleteState(name string) error { // Determine the path of the data path := b.path(name) - // Delete it + // 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 }