blob: fb2c24a90dac5d9e4f6dc8a832805c85a9ec4069 [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
// 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/zclconf/go-cty/cty"
"golang.org/x/oauth2"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/backend/backendbase"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// Backend implements "backend".Backend for GCS.
// Schema() and PrepareConfig() are implemented by embedding backendbase.Base.
// Configure(), State(), DeleteState() and States() are implemented explicitly.
type Backend struct {
backendbase.Base
storageClient *storage.Client
bucketName string
prefix string
encryptionKey []byte
kmsKeyName string
}
func New() backend.Backend {
return &Backend{
Base: backendbase.Base{
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bucket": {
Type: cty.String,
Required: true,
Description: "The name of the Google Cloud Storage bucket",
},
"prefix": {
Type: cty.String,
Optional: true,
Description: "The directory where state files will be saved inside the bucket",
},
"credentials": {
Type: cty.String,
Optional: true,
Description: "Google Cloud JSON Account Key",
},
"access_token": {
Type: cty.String,
Optional: true,
Description: "An OAuth2 token used for GCP authentication",
},
"impersonate_service_account": {
Type: cty.String,
Optional: true,
Description: "The service account to impersonate for all Google API Calls",
},
"impersonate_service_account_delegates": {
Type: cty.List(cty.String),
Optional: true,
Description: "The delegation chain for the impersonated service account",
},
"encryption_key": {
Type: cty.String,
Optional: true,
Description: "A 32 byte base64 encoded 'customer supplied encryption key' used when reading and writing state files in the bucket.",
},
"kms_encryption_key": {
Type: cty.String,
Optional: true,
Description: "A Cloud KMS key ('customer managed encryption key') used when reading and writing state files in the bucket. Format should be 'projects/{{project}}/locations/{{location}}/keyRings/{{keyRing}}/cryptoKeys/{{name}}'.",
},
"storage_custom_endpoint": {
Type: cty.String,
Optional: true,
},
},
},
SDKLikeDefaults: backendbase.SDKLikeDefaults{
"prefix": {
Fallback: "",
},
"credentials": {
Fallback: "",
},
"access_token": {
EnvVars: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"},
},
"impersonate_service_account": {
EnvVars: []string{
"GOOGLE_BACKEND_IMPERSONATE_SERVICE_ACCOUNT",
"GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
},
},
"encryption_key": {
EnvVars: []string{"GOOGLE_ENCRYPTION_KEY"},
},
"kms_encryption_key": {
EnvVars: []string{"GOOGLE_KMS_ENCRYPTION_KEY"},
},
"storage_custom_endpoint": {
EnvVars: []string{
"GOOGLE_BACKEND_STORAGE_CUSTOM_ENDPOINT",
"GOOGLE_STORAGE_CUSTOM_ENDPOINT",
},
},
},
},
}
}
func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
if b.storageClient != nil {
return nil
}
// TODO: Update the Backend API to pass the real context.Context from
// the running command.
ctx := context.TODO()
data := backendbase.NewSDKLikeData(configVal)
if data.String("encryption_key") != "" && data.String("kms_encryption_key") != "" {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("can't set both encryption_key and kms_encryption_key"),
)
}
// The above catches the main case where both of the arguments are set to
// a non-empty value, but we also want to reject the situation where
// both are present in the configuration regardless of what values were
// assigned to them. (This check doesn't take the environment variables
// into account, so must allow neither to be set in the main configuration.)
if !(configVal.GetAttr("encryption_key").IsNull() || configVal.GetAttr("kms_encryption_key").IsNull()) {
// This rejects a configuration like:
// encryption_key = ""
// kms_encryption_key = ""
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("can't set both encryption_key and kms_encryption_key"),
)
}
b.bucketName = data.String("bucket")
b.prefix = strings.TrimLeft(data.String("prefix"), "/")
if b.prefix != "" && !strings.HasSuffix(b.prefix, "/") {
b.prefix = b.prefix + "/"
}
var opts []option.ClientOption
var credOptions []option.ClientOption
// Add credential source
var creds string
var tokenSource oauth2.TokenSource
if v := data.String("access_token"); v != "" {
tokenSource = oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: v,
})
} else if v := data.String("credentials"); v != "" {
creds = v
} else if v := os.Getenv("GOOGLE_BACKEND_CREDENTIALS"); v != "" {
creds = v
} else {
creds = os.Getenv("GOOGLE_CREDENTIALS")
}
if tokenSource != nil {
credOptions = append(credOptions, option.WithTokenSource(tokenSource))
} else if creds != "" {
// to mirror how the provider works, we accept the file path or the contents
contents, err := readPathOrContents(creds)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("Error loading credentials: %s", err),
)
}
if !json.Valid([]byte(contents)) {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("the string provided in credentials is neither valid json nor a valid file path"),
)
}
credOptions = append(credOptions, option.WithCredentialsJSON([]byte(contents)))
}
// Service Account Impersonation
if v := data.String("impersonate_service_account"); v != "" {
ServiceAccount := v
var delegates []string
delegatesVal := data.GetAttr("impersonate_service_account_delegates", cty.List(cty.String))
if !delegatesVal.IsNull() && delegatesVal.LengthInt() != 0 {
delegates = make([]string, 0, delegatesVal.LengthInt())
for it := delegatesVal.ElementIterator(); it.Next(); {
_, v := it.Element()
if v.IsNull() {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("impersonate_service_account_delegates elements must not be null"),
)
}
delegates = append(delegates, v.AsString())
}
}
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: ServiceAccount,
Scopes: []string{storage.ScopeReadWrite},
Delegates: delegates,
}, credOptions...)
if err != nil {
return backendbase.ErrorAsDiagnostics(err)
}
opts = append(opts, option.WithTokenSource(ts))
} else {
opts = append(opts, credOptions...)
}
opts = append(opts, option.WithUserAgent(httpclient.UserAgentString()))
// Custom endpoint for storage API
if storageEndpoint := data.String("storage_custom_endpoint"); storageEndpoint != "" {
endpoint := option.WithEndpoint(storageEndpoint)
opts = append(opts, endpoint)
}
client, err := storage.NewClient(ctx, opts...)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("storage.NewClient() failed: %v", err),
)
}
b.storageClient = client
// Customer-supplied encryption
key := data.String("encryption_key")
if key != "" {
kc, err := readPathOrContents(key)
if err != nil {
return backendbase.ErrorAsDiagnostics(
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 backendbase.ErrorAsDiagnostics(
fmt.Errorf("Error decoding encryption key: %s", err),
)
}
b.encryptionKey = k
}
// Customer-managed encryption
kmsName := data.String("kms_encryption_key")
if kmsName != "" {
b.kmsKeyName = kmsName
}
return nil
}