opentofu/backend/remote-state/gcs/backend.go
Paddy Carver b8752c7610 Add a backend-specific env var for the GCS backend.
Right now, the only environment variable available is the same
environment variable that will be picked up by the GCP provider. Users
would like to be able to store state in separate projects or accounts or
otherwise authenticate to the provider with a service account that
doesn't have access to the state. This seems like a reasonable enough
practice to me, and the solution seems straightforward--offer an
environment variable that doesn't mean anything to the provider to
configure the backend credentials. I've added GOOGLE_BACKEND_CREDENTIALS
to manage just the backend credentials, and documented it appropriately.
2019-12-12 03:35:39 -08:00

214 lines
6.0 KiB
Go

// Package gcs implements remote storage of state on Google Cloud Storage (GCS).
package gcs
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"cloud.google.com/go/storage"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/helper/pathorcontents"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/httpclient"
"golang.org/x/oauth2"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/option"
)
// Backend implements "backend".Backend for GCS.
// Input(), Validate() and Configure() are implemented by embedding *schema.Backend.
// State(), DeleteState() and States() are implemented explicitly.
type Backend struct {
*schema.Backend
storageClient *storage.Client
storageContext context.Context
bucketName string
prefix string
defaultStateFile string
encryptionKey []byte
}
func New() backend.Backend {
b := &Backend{}
b.Backend = &schema.Backend{
ConfigureFunc: b.configure,
Schema: map[string]*schema.Schema{
"bucket": {
Type: schema.TypeString,
Required: true,
Description: "The name of the Google Cloud Storage bucket",
},
"path": {
Type: schema.TypeString,
Optional: true,
Description: "Path of the default state file",
Deprecated: "Use the \"prefix\" option instead",
},
"prefix": {
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: "",
},
"access_token": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_OAUTH_ACCESS_TOKEN",
}, nil),
Description: "An OAuth2 token used for GCP authentication",
},
"encryption_key": {
Type: schema.TypeString,
Optional: true,
Description: "A 32 byte base64 encoded 'customer supplied encryption key' used to encrypt all state.",
Default: "",
},
"project": {
Type: schema.TypeString,
Optional: true,
Description: "Google Cloud Project ID",
Default: "",
Removed: "Please remove this attribute. It is not used since the backend no longer creates the bucket if it does not yet exist.",
},
"region": {
Type: schema.TypeString,
Optional: true,
Description: "Region / location in which to create the bucket",
Default: "",
Removed: "Please remove this attribute. It is not used since the backend no longer creates the bucket if it does not yet exist.",
},
},
}
return b
}
func (b *Backend) configure(ctx context.Context) error {
if b.storageClient != nil {
return nil
}
// ctx is a background context with the backend config added.
// Since no context is passed to remoteClient.Get(), .Lock(), etc. but
// one is required for calling the GCP API, we're holding on to this
// context here and re-use it later.
b.storageContext = ctx
data := schema.FromContextBackendConfig(b.storageContext)
b.bucketName = data.Get("bucket").(string)
b.prefix = strings.TrimLeft(data.Get("prefix").(string), "/")
if b.prefix != "" && !strings.HasSuffix(b.prefix, "/") {
b.prefix = b.prefix + "/"
}
b.defaultStateFile = strings.TrimLeft(data.Get("path").(string), "/")
var opts []option.ClientOption
// Add credential source
var creds string
var tokenSource oauth2.TokenSource
if v, ok := data.GetOk("access_token"); ok {
tokenSource = oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: v.(string),
})
} else if v, ok := data.GetOk("credentials"); ok {
creds = v.(string)
} else if v := os.Getenv("GOOGLE_BACKEND_CREDENTIALS"); v != "" {
creds = v
} else {
creds = os.Getenv("GOOGLE_CREDENTIALS")
}
if tokenSource != nil {
opts = append(opts, option.WithTokenSource(tokenSource))
} else if creds != "" {
var account accountFile
// to mirror how the provider works, we accept the file path or the contents
contents, _, err := pathorcontents.Read(creds)
if err != nil {
return fmt.Errorf("Error loading credentials: %s", err)
}
if err := json.Unmarshal([]byte(contents), &account); err != nil {
return fmt.Errorf("Error parsing credentials '%s': %s", contents, err)
}
conf := jwt.Config{
Email: account.ClientEmail,
PrivateKey: []byte(account.PrivateKey),
Scopes: []string{storage.ScopeReadWrite},
TokenURL: "https://oauth2.googleapis.com/token",
}
opts = append(opts, option.WithHTTPClient(conf.Client(ctx)))
} else {
opts = append(opts, option.WithScopes(storage.ScopeReadWrite))
}
opts = append(opts, option.WithUserAgent(httpclient.UserAgentString()))
client, err := storage.NewClient(b.storageContext, opts...)
if err != nil {
return fmt.Errorf("storage.NewClient() failed: %v", err)
}
b.storageClient = client
key := data.Get("encryption_key").(string)
if key == "" {
key = os.Getenv("GOOGLE_ENCRYPTION_KEY")
}
if key != "" {
kc, _, err := pathorcontents.Read(key)
if err != nil {
return fmt.Errorf("Error loading encryption key: %s", err)
}
// The GCS client expects a customer supplied encryption key to be
// passed in as a 32 byte long byte slice. The byte slice is base64
// encoded before being passed to the API. We take a base64 encoded key
// to remain consistent with the GCS docs.
// https://cloud.google.com/storage/docs/encryption#customer-supplied
// https://github.com/GoogleCloudPlatform/google-cloud-go/blob/def681/storage/storage.go#L1181
k, err := base64.StdEncoding.DecodeString(kc)
if err != nil {
return fmt.Errorf("Error decoding encryption key: %s", err)
}
b.encryptionKey = k
}
return nil
}
// accountFile represents the structure of the account file JSON file.
type accountFile struct {
PrivateKeyId string `json:"private_key_id"`
PrivateKey string `json:"private_key"`
ClientEmail string `json:"client_email"`
ClientId string `json:"client_id"`
}