mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-18 20:52:58 -06:00
b8752c7610
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.
214 lines
6.0 KiB
Go
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"`
|
|
}
|