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

etcd v3 backend with lock support. #15680

Merged
merged 16 commits into from
Oct 3, 2017
Merged
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 .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ go:

# add TF_CONSUL_TEST=1 to run consul tests
# they were causing timouts in travis
# add TF_ETCDV3_TEST=1 to run etcdv3 tests
# if added, TF_ETCDV3_ENDPOINTS must be set to a comma-separated list of (insecure) etcd endpoints against which to test
env:
- CONSUL_VERSION=0.7.5 GOMAXPROCS=4

Expand Down
2 changes: 2 additions & 0 deletions backend/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
backendlocal "github.com/hashicorp/terraform/backend/local"
backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure"
backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul"
backendetcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
backendinmem "github.com/hashicorp/terraform/backend/remote-state/inmem"
backendS3 "github.com/hashicorp/terraform/backend/remote-state/s3"
backendSwift "github.com/hashicorp/terraform/backend/remote-state/swift"
Expand Down Expand Up @@ -45,6 +46,7 @@ func init() {
"azure": deprecateBackend(backendAzure.New(),
`Warning: "azure" name is deprecated, please use "azurerm"`),
"azurerm": func() backend.Backend { return backendAzure.New() },
"etcdv3": func() backend.Backend { return backendetcdv3.New() },
}

// Add the legacy remote backends that haven't yet been convertd to
Expand Down
157 changes: 157 additions & 0 deletions backend/remote-state/etcdv3/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package etcd

import (
"context"

etcdv3 "github.com/coreos/etcd/clientv3"
"github.com/coreos/etcd/pkg/transport"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/helper/schema"
)

const (
endpointsKey = "endpoints"
usernameKey = "username"
usernameEnvVarName = "ETCDV3_USERNAME"
passwordKey = "password"
passwordEnvVarName = "ETCDV3_PASSWORD"
prefixKey = "prefix"
lockKey = "lock"
cacertPathKey = "cacert_path"
certPathKey = "cert_path"
keyPathKey = "key_path"
)

func New() backend.Backend {
s := &schema.Backend{
Schema: map[string]*schema.Schema{
endpointsKey: &schema.Schema{
Type: schema.TypeList,
Elem: &schema.Schema{
Type: schema.TypeString,
},
MinItems: 1,
Required: true,
Description: "Endpoints for the etcd cluster.",
},

usernameKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "Username used to connect to the etcd cluster.",
DefaultFunc: schema.EnvDefaultFunc(usernameEnvVarName, ""),
},

passwordKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "Password used to connect to the etcd cluster.",
DefaultFunc: schema.EnvDefaultFunc(passwordEnvVarName, ""),
},

prefixKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "An optional prefix to be added to keys when to storing state in etcd.",
Default: "",
},

lockKey: &schema.Schema{
Type: schema.TypeBool,
Optional: true,
Description: "Whether to lock state access.",
Default: true,
},

cacertPathKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "The path to a PEM-encoded CA bundle with which to verify certificates of TLS-enabled etcd servers.",
Default: "",
},

certPathKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "The path to a PEM-encoded certificate to provide to etcd for secure client identification.",
Default: "",
},

keyPathKey: &schema.Schema{
Type: schema.TypeString,
Optional: true,
Description: "The path to a PEM-encoded key to provide to etcd for secure client identification.",
Default: "",
},
},
}

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

type Backend struct {
*schema.Backend

// The fields below are set from configure.
client *etcdv3.Client
data *schema.ResourceData
lock bool
prefix string
}

func (b *Backend) configure(ctx context.Context) error {
var err error
// Grab the resource data.
b.data = schema.FromContextBackendConfig(ctx)
// Store the lock information.
b.lock = b.data.Get(lockKey).(bool)
// Store the prefix information.
b.prefix = b.data.Get(prefixKey).(string)
// Initialize a client to test config.
b.client, err = b.rawClient()
// Return err, if any.
return err
}

func (b *Backend) rawClient() (*etcdv3.Client, error) {
config := etcdv3.Config{}
tlsInfo := transport.TLSInfo{}

if v, ok := b.data.GetOk(endpointsKey); ok {
config.Endpoints = retrieveEndpoints(v)
}
if v, ok := b.data.GetOk(usernameKey); ok && v.(string) != "" {
config.Username = v.(string)
}
if v, ok := b.data.GetOk(passwordKey); ok && v.(string) != "" {
config.Password = v.(string)
}
if v, ok := b.data.GetOk(cacertPathKey); ok && v.(string) != "" {
tlsInfo.TrustedCAFile = v.(string)
}
if v, ok := b.data.GetOk(certPathKey); ok && v.(string) != "" {
tlsInfo.CertFile = v.(string)
}
if v, ok := b.data.GetOk(keyPathKey); ok && v.(string) != "" {
tlsInfo.KeyFile = v.(string)
}

if tlsCfg, err := tlsInfo.ClientConfig(); err != nil {
return nil, err
} else if !tlsInfo.Empty() {
config.TLS = tlsCfg // Assign TLS configuration only if it valid and non-empty.
}

return etcdv3.New(config)
}

func retrieveEndpoints(v interface{}) []string {
var endpoints []string
list := v.([]interface{})
for _, ep := range list {
endpoints = append(endpoints, ep.(string))
}
return endpoints
}
103 changes: 103 additions & 0 deletions backend/remote-state/etcdv3/backend_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package etcd

import (
"context"
"fmt"
"sort"
"strings"

etcdv3 "github.com/coreos/etcd/clientv3"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/state"
"github.com/hashicorp/terraform/state/remote"
"github.com/hashicorp/terraform/terraform"
)

func (b *Backend) States() ([]string, error) {
res, err := b.client.Get(context.TODO(), b.prefix, etcdv3.WithPrefix(), etcdv3.WithKeysOnly())
if err != nil {
return nil, err
}

result := make([]string, 1, len(res.Kvs)+1)
result[0] = backend.DefaultStateName
for _, kv := range res.Kvs {
result = append(result, strings.TrimPrefix(string(kv.Key), b.prefix))
}
sort.Strings(result[1:])

return result, nil
}

func (b *Backend) DeleteState(name string) error {
if name == backend.DefaultStateName || name == "" {
return fmt.Errorf("Can't delete default state.")
}

key := b.determineKey(name)

_, err := b.client.Delete(context.TODO(), key)
return err
}

func (b *Backend) State(name string) (state.State, error) {
var stateMgr state.State = &remote.State{
Client: &RemoteClient{
Client: b.client,
DoLock: b.lock,
Key: b.determineKey(name),
},
}

if !b.lock {
stateMgr = &state.LockDisabled{Inner: stateMgr}
}

lockInfo := state.NewLockInfo()
lockInfo.Operation = "init"
lockId, err := stateMgr.Lock(lockInfo)
if err != nil {
return nil, fmt.Errorf("Failed to lock state in etcd: %s.", err)
}

lockUnlock := func(parent error) error {
if err := stateMgr.Unlock(lockId); err != nil {
return fmt.Errorf(strings.TrimSpace(errStateUnlock), lockId, err)
}
return parent
}

if err := stateMgr.RefreshState(); err != nil {
err = lockUnlock(err)
return nil, err
}

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

if err := lockUnlock(nil); err != nil {
return nil, err
}

return stateMgr, nil
}

func (b *Backend) determineKey(name string) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the changes in etcd means that there isn't going to be a direct upgrade from etcd2, you can probably get rid of the legacy default state path, and assume everything has a "workspace" and get rid of the conditionals around keyEnvPrefixand such.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(...) you can probably get rid of the legacy default state path (...) and get rid of the conditionals around keyEnvPrefix and such.

@jbardin I'm not sure I understand what you mean. 😶 Could you please clarify/exemplify?

Copy link
Member

@jbardin jbardin Sep 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that the reason the other backends have the "default" state handled separately is solely for backwards compatibility with state files that existed before envs/workspaces.

So rather than dealing with the keyEnvPrefix you've adopted from the others, you could use the same hierarchy in all cases of prefix/name, and just include "default" in there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done @ 3c21b9c. Thank you for your guidance. 🙂

return b.prefix + name
}

const errStateUnlock = `
Error unlocking etcd state. Lock ID: %s

Error: %s

You may have to force-unlock this state in order to use it again.
`
93 changes: 93 additions & 0 deletions backend/remote-state/etcdv3/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package etcd

import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"

etcdv3 "github.com/coreos/etcd/clientv3"
"github.com/hashicorp/terraform/backend"
)

var (
etcdv3Endpoints = strings.Split(os.Getenv("TF_ETCDV3_ENDPOINTS"), ",")
)

const (
keyPrefix = "tf-unit"
)

func TestBackend_impl(t *testing.T) {
var _ backend.Backend = new(Backend)
}

func cleanupEtcdv3(t *testing.T) {
client, err := etcdv3.New(etcdv3.Config{
Endpoints: etcdv3Endpoints,
})
if err != nil {
t.Fatal(err)
}

res, err := client.KV.Delete(context.TODO(), keyPrefix, etcdv3.WithPrefix())
if err != nil {
t.Fatal(err)
}
t.Logf("Cleaned up %d keys.", res.Deleted)
}

func prepareEtcdv3(t *testing.T) {
skip := os.Getenv("TF_ACC") == "" && os.Getenv("TF_ETCDV3_TEST") == ""
if skip {
t.Log("etcd server tests require setting TF_ACC or TF_ETCDV3_TEST")
t.Skip()
}
cleanupEtcdv3(t)
}

func TestBackend(t *testing.T) {
prepareEtcdv3(t)
defer cleanupEtcdv3(t)

prefix := fmt.Sprintf("%s/%s/", keyPrefix, time.Now().Format(time.RFC3339))

// Get the backend. We need two to test locking.
b1 := backend.TestBackendConfig(t, New(), map[string]interface{}{
"endpoints": etcdv3Endpoints,
"prefix": prefix,
})

b2 := backend.TestBackendConfig(t, New(), map[string]interface{}{
"endpoints": etcdv3Endpoints,
"prefix": prefix,
})

// Test
backend.TestBackend(t, b1, b2)
}

func TestBackend_lockDisabled(t *testing.T) {
prepareEtcdv3(t)
defer cleanupEtcdv3(t)

prefix := fmt.Sprintf("%s/%s/", keyPrefix, time.Now().Format(time.RFC3339))

// Get the backend. We need two to test locking.
b1 := backend.TestBackendConfig(t, New(), map[string]interface{}{
"endpoints": etcdv3Endpoints,
"prefix": prefix,
"lock": false,
})

b2 := backend.TestBackendConfig(t, New(), map[string]interface{}{
"endpoints": etcdv3Endpoints,
"prefix": prefix + "/" + "different", // Diff so locking test would fail if it was locking
"lock": false,
})

// Test
backend.TestBackend(t, b1, b2)
}
Loading