Implemented GCloud backend supporting remote locking and multiple workspaces.
This commit is contained in:
parent
633d428c15
commit
5854373018
|
@ -14,6 +14,7 @@ import (
|
||||||
backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure"
|
backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure"
|
||||||
backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul"
|
backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul"
|
||||||
backendetcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
|
backendetcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3"
|
||||||
|
backendGCloud "github.com/hashicorp/terraform/backend/remote-state/gcloud"
|
||||||
backendinmem "github.com/hashicorp/terraform/backend/remote-state/inmem"
|
backendinmem "github.com/hashicorp/terraform/backend/remote-state/inmem"
|
||||||
backendS3 "github.com/hashicorp/terraform/backend/remote-state/s3"
|
backendS3 "github.com/hashicorp/terraform/backend/remote-state/s3"
|
||||||
backendSwift "github.com/hashicorp/terraform/backend/remote-state/swift"
|
backendSwift "github.com/hashicorp/terraform/backend/remote-state/swift"
|
||||||
|
@ -47,6 +48,7 @@ func init() {
|
||||||
`Warning: "azure" name is deprecated, please use "azurerm"`),
|
`Warning: "azure" name is deprecated, please use "azurerm"`),
|
||||||
"azurerm": func() backend.Backend { return backendAzure.New() },
|
"azurerm": func() backend.Backend { return backendAzure.New() },
|
||||||
"etcdv3": func() backend.Backend { return backendetcdv3.New() },
|
"etcdv3": func() backend.Backend { return backendetcdv3.New() },
|
||||||
|
"gcloud": func() backend.Backend { return backendGCloud.New() },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the legacy remote backends that haven't yet been convertd to
|
// Add the legacy remote backends that haven't yet been convertd to
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
package gcloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/hashicorp/terraform/backend"
|
||||||
|
"github.com/hashicorp/terraform/helper/pathorcontents"
|
||||||
|
"github.com/hashicorp/terraform/helper/schema"
|
||||||
|
googleContext "golang.org/x/net/context"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() backend.Backend {
|
||||||
|
s := &schema.Backend{
|
||||||
|
Schema: map[string]*schema.Schema{
|
||||||
|
"bucket": {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Required: true,
|
||||||
|
Description: "The name of the Google Cloud Storage bucket",
|
||||||
|
},
|
||||||
|
|
||||||
|
"state_dir": {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: "The directory where state files will be saved inside the bucket",
|
||||||
|
},
|
||||||
|
|
||||||
|
"credentials": {
|
||||||
|
Type: schema.TypeString,
|
||||||
|
Optional: true,
|
||||||
|
Description: "Google Cloud JSON Account Key",
|
||||||
|
Default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &Backend{Backend: s}
|
||||||
|
result.Backend.ConfigureFunc = result.configure
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type Backend struct {
|
||||||
|
*schema.Backend
|
||||||
|
|
||||||
|
storageClient *storage.Client
|
||||||
|
storageContext googleContext.Context
|
||||||
|
|
||||||
|
bucketName string
|
||||||
|
stateDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) configure(ctx context.Context) error {
|
||||||
|
if b.storageClient != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
storageOAuth2Scope := "https://www.googleapis.com/auth/devstorage.read_write"
|
||||||
|
|
||||||
|
data := schema.FromContextBackendConfig(ctx)
|
||||||
|
|
||||||
|
b.bucketName = data.Get("bucket").(string)
|
||||||
|
b.stateDir = data.Get("state_dir").(string)
|
||||||
|
b.storageContext = googleContext.Background()
|
||||||
|
|
||||||
|
credentials := data.Get("credentials").(string)
|
||||||
|
|
||||||
|
var tokenSource oauth2.TokenSource
|
||||||
|
|
||||||
|
if credentials != "" {
|
||||||
|
credentialsJson, _, err := pathorcontents.Read(data.Get("credentials").(string))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error loading credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtConfig, err := google.JWTConfigFromJSON([]byte(credentialsJson), storageOAuth2Scope)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to get Google OAuth2 token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSource = jwtConfig.TokenSource(b.storageContext)
|
||||||
|
} else {
|
||||||
|
defaultTokenSource, err := google.DefaultTokenSource(b.storageContext, storageOAuth2Scope)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to get Google Application Default Credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSource = defaultTokenSource
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := storage.NewClient(b.storageContext, option.WithTokenSource(tokenSource))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to create Google Storage client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.storageClient = client
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,165 @@
|
||||||
|
package gcloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/hashicorp/terraform/backend"
|
||||||
|
"github.com/hashicorp/terraform/state"
|
||||||
|
"github.com/hashicorp/terraform/state/remote"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
|
"google.golang.org/api/iterator"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Backend) States() ([]string, error) {
|
||||||
|
workspaces := []string{backend.DefaultStateName}
|
||||||
|
var stateRegex *regexp.Regexp
|
||||||
|
var err error
|
||||||
|
if b.stateDir == "" {
|
||||||
|
stateRegex = regexp.MustCompile(`^(.+)\.tfstate$`)
|
||||||
|
} else {
|
||||||
|
stateRegex, err = regexp.Compile(fmt.Sprintf("^%v/(.+)\\.tfstate$", regexp.QuoteMeta(b.stateDir)))
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, fmt.Errorf("Failed to compile regex for querying states: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket := b.storageClient.Bucket(b.bucketName)
|
||||||
|
query := &storage.Query{
|
||||||
|
Prefix: b.stateDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
files := bucket.Objects(b.storageContext, query)
|
||||||
|
for {
|
||||||
|
attrs, err := files.Next()
|
||||||
|
if err == iterator.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, fmt.Errorf("Failed to query remote states: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := stateRegex.FindStringSubmatch(attrs.Name)
|
||||||
|
if len(matches) == 2 && matches[1] != backend.DefaultStateName {
|
||||||
|
workspaces = append(workspaces, matches[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(workspaces[1:])
|
||||||
|
return workspaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) DeleteState(name string) error {
|
||||||
|
if name == backend.DefaultStateName || name == "" {
|
||||||
|
return fmt.Errorf("Can't delete default state")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := b.remoteClient(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to create Google Storage client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.Delete()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to delete state file %v: %v", client.stateFileUrl(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get a remote client configured for this state
|
||||||
|
func (b *Backend) remoteClient(name string) (*RemoteClient, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("Missing state name")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &RemoteClient{
|
||||||
|
storageContext: b.storageContext,
|
||||||
|
storageClient: b.storageClient,
|
||||||
|
bucketName: b.bucketName,
|
||||||
|
stateFilePath: b.stateFileName(name),
|
||||||
|
lockFilePath: b.lockFileName(name),
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) State(name string) (state.State, error) {
|
||||||
|
client, err := b.remoteClient(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to create Google Storage client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMgr := &remote.State{Client: client}
|
||||||
|
lockInfo := state.NewLockInfo()
|
||||||
|
lockInfo.Operation = "init"
|
||||||
|
lockId, err := stateMgr.Lock(lockInfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to lock state in Google Cloud Storage: %v", 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, client.lockFileUrl(), 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) stateFileName(stateName string) string {
|
||||||
|
if b.stateDir == "" {
|
||||||
|
return fmt.Sprintf("%v.tfstate", stateName)
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%v/%v.tfstate", b.stateDir, stateName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) lockFileName(stateName string) string {
|
||||||
|
if b.stateDir == "" {
|
||||||
|
return fmt.Sprintf("%v.tflock", stateName)
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%v/%v.tflock", b.stateDir, stateName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errStateUnlock = `
|
||||||
|
Error unlocking Google Cloud Storage state.
|
||||||
|
|
||||||
|
Lock ID: %v
|
||||||
|
Lock file URL: %v
|
||||||
|
Error: %v
|
||||||
|
|
||||||
|
You may have to force-unlock this state in order to use it again.
|
||||||
|
The GCloud backend acquires a lock during initialization to ensure
|
||||||
|
the initial state file is created.
|
||||||
|
`
|
|
@ -0,0 +1,168 @@
|
||||||
|
package gcloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
uuid "github.com/hashicorp/go-uuid"
|
||||||
|
"github.com/hashicorp/terraform/state"
|
||||||
|
"github.com/hashicorp/terraform/state/remote"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
"io/ioutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteClient struct {
|
||||||
|
storageContext context.Context
|
||||||
|
storageClient *storage.Client
|
||||||
|
bucketName string
|
||||||
|
stateFilePath string
|
||||||
|
lockFilePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Get() (payload *remote.Payload, err error) {
|
||||||
|
bucket := c.storageClient.Bucket(c.bucketName)
|
||||||
|
stateFile := bucket.Object(c.stateFilePath)
|
||||||
|
stateFileUrl := c.stateFileUrl()
|
||||||
|
|
||||||
|
stateFileReader, err := stateFile.NewReader(c.storageContext)
|
||||||
|
if err != nil {
|
||||||
|
if err == storage.ErrObjectNotExist {
|
||||||
|
return nil, nil
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("Failed to open state file at %v: %v", stateFileUrl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer stateFileReader.Close()
|
||||||
|
|
||||||
|
stateFileContents, err := ioutil.ReadAll(stateFileReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to read state file from %v: %v", stateFileUrl, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFileAttrs, err := stateFile.Attrs(c.storageContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed to read state file attrs from %v: %v", stateFileUrl, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &remote.Payload{
|
||||||
|
Data: stateFileContents,
|
||||||
|
MD5: stateFileAttrs.MD5,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Put(data []byte) error {
|
||||||
|
bucket := c.storageClient.Bucket(c.bucketName)
|
||||||
|
stateFile := bucket.Object(c.stateFilePath)
|
||||||
|
|
||||||
|
stateFileWriter := stateFile.NewWriter(c.storageContext)
|
||||||
|
|
||||||
|
stateFileWriter.Write(data)
|
||||||
|
err := stateFileWriter.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to upload state to %v: %v", c.stateFileUrl(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Delete() error {
|
||||||
|
bucket := c.storageClient.Bucket(c.bucketName)
|
||||||
|
stateFile := bucket.Object(c.stateFilePath)
|
||||||
|
|
||||||
|
err := stateFile.Delete(c.storageContext)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to delete state file %v: %v", c.stateFileUrl(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Lock(info *state.LockInfo) (string, error) {
|
||||||
|
if info.ID == "" {
|
||||||
|
lockID, err := uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.ID = lockID
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Path = c.lockFileUrl()
|
||||||
|
|
||||||
|
infoJson, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket := c.storageClient.Bucket(c.bucketName)
|
||||||
|
lockFile := bucket.Object(c.lockFilePath)
|
||||||
|
|
||||||
|
writer := lockFile.If(storage.Conditions{DoesNotExist: true}).NewWriter(c.storageContext)
|
||||||
|
writer.Write(infoJson)
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("Error while saving lock file (%v): %v", info.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) Unlock(id string) error {
|
||||||
|
lockErr := &state.LockError{}
|
||||||
|
|
||||||
|
bucket := c.storageClient.Bucket(c.bucketName)
|
||||||
|
lockFile := bucket.Object(c.lockFilePath)
|
||||||
|
lockFileUrl := c.lockFileUrl()
|
||||||
|
|
||||||
|
lockFileReader, err := lockFile.NewReader(c.storageContext)
|
||||||
|
if err != nil {
|
||||||
|
lockErr.Err = fmt.Errorf("Failed to retrieve lock info (%v): %v", lockFileUrl, err)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
defer lockFileReader.Close()
|
||||||
|
|
||||||
|
lockFileContents, err := ioutil.ReadAll(lockFileReader)
|
||||||
|
if err != nil {
|
||||||
|
lockErr.Err = fmt.Errorf("Failed to retrieve lock info (%v): %v", lockFileUrl, err)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
lockInfo := &state.LockInfo{}
|
||||||
|
err = json.Unmarshal(lockFileContents, lockInfo)
|
||||||
|
if err != nil {
|
||||||
|
lockErr.Err = fmt.Errorf("Failed to unmarshal lock info (%v): %v", lockFileUrl, err)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
lockErr.Info = lockInfo
|
||||||
|
|
||||||
|
if lockInfo.ID != id {
|
||||||
|
lockErr.Err = fmt.Errorf("Lock id %q does not match existing lock", id)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
lockFileAttrs, err := lockFile.Attrs(c.storageContext)
|
||||||
|
if err != nil {
|
||||||
|
lockErr.Err = fmt.Errorf("Failed to fetch lock file attrs (%v): %v", lockFileUrl, err)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
err = lockFile.If(storage.Conditions{GenerationMatch: lockFileAttrs.Generation}).Delete(c.storageContext)
|
||||||
|
if err != nil {
|
||||||
|
lockErr.Err = fmt.Errorf("Failed to delete lock file (%v): %v", lockFileUrl, err)
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) stateFileUrl() string {
|
||||||
|
return fmt.Sprintf("gs://%v/%v", c.bucketName, c.stateFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RemoteClient) lockFileUrl() string {
|
||||||
|
return fmt.Sprintf("gs://%v/%v", c.bucketName, c.lockFilePath)
|
||||||
|
}
|
Loading…
Reference in New Issue