github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/backend/remote-state/gcs/backend.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  // Package gcs implements remote storage of state on Google Cloud Storage (GCS).
     5  package gcs
     6  
     7  import (
     8  	"context"
     9  	"encoding/base64"
    10  	"encoding/json"
    11  	"fmt"
    12  	"os"
    13  	"strings"
    14  
    15  	"cloud.google.com/go/storage"
    16  	"github.com/terramate-io/tf/backend"
    17  	"github.com/terramate-io/tf/httpclient"
    18  	"github.com/terramate-io/tf/legacy/helper/schema"
    19  	"golang.org/x/oauth2"
    20  	"google.golang.org/api/impersonate"
    21  	"google.golang.org/api/option"
    22  )
    23  
    24  // Backend implements "backend".Backend for GCS.
    25  // Input(), Validate() and Configure() are implemented by embedding *schema.Backend.
    26  // State(), DeleteState() and States() are implemented explicitly.
    27  type Backend struct {
    28  	*schema.Backend
    29  
    30  	storageClient  *storage.Client
    31  	storageContext context.Context
    32  
    33  	bucketName string
    34  	prefix     string
    35  
    36  	encryptionKey []byte
    37  	kmsKeyName    string
    38  }
    39  
    40  func New() backend.Backend {
    41  	b := &Backend{}
    42  	b.Backend = &schema.Backend{
    43  		ConfigureFunc: b.configure,
    44  		Schema: map[string]*schema.Schema{
    45  			"bucket": {
    46  				Type:        schema.TypeString,
    47  				Required:    true,
    48  				Description: "The name of the Google Cloud Storage bucket",
    49  			},
    50  
    51  			"prefix": {
    52  				Type:        schema.TypeString,
    53  				Optional:    true,
    54  				Description: "The directory where state files will be saved inside the bucket",
    55  			},
    56  
    57  			"credentials": {
    58  				Type:        schema.TypeString,
    59  				Optional:    true,
    60  				Description: "Google Cloud JSON Account Key",
    61  				Default:     "",
    62  			},
    63  
    64  			"access_token": {
    65  				Type:     schema.TypeString,
    66  				Optional: true,
    67  				DefaultFunc: schema.MultiEnvDefaultFunc([]string{
    68  					"GOOGLE_OAUTH_ACCESS_TOKEN",
    69  				}, nil),
    70  				Description: "An OAuth2 token used for GCP authentication",
    71  			},
    72  
    73  			"impersonate_service_account": {
    74  				Type:     schema.TypeString,
    75  				Optional: true,
    76  				DefaultFunc: schema.MultiEnvDefaultFunc([]string{
    77  					"GOOGLE_BACKEND_IMPERSONATE_SERVICE_ACCOUNT",
    78  					"GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
    79  				}, nil),
    80  				Description: "The service account to impersonate for all Google API Calls",
    81  			},
    82  
    83  			"impersonate_service_account_delegates": {
    84  				Type:        schema.TypeList,
    85  				Optional:    true,
    86  				Description: "The delegation chain for the impersonated service account",
    87  				Elem:        &schema.Schema{Type: schema.TypeString},
    88  			},
    89  
    90  			"encryption_key": {
    91  				Type:     schema.TypeString,
    92  				Optional: true,
    93  				DefaultFunc: schema.MultiEnvDefaultFunc([]string{
    94  					"GOOGLE_ENCRYPTION_KEY",
    95  				}, nil),
    96  				Description:   "A 32 byte base64 encoded 'customer supplied encryption key' used when reading and writing state files in the bucket.",
    97  				ConflictsWith: []string{"kms_encryption_key"},
    98  			},
    99  
   100  			"kms_encryption_key": {
   101  				Type:     schema.TypeString,
   102  				Optional: true,
   103  				DefaultFunc: schema.MultiEnvDefaultFunc([]string{
   104  					"GOOGLE_KMS_ENCRYPTION_KEY",
   105  				}, nil),
   106  				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}}'.",
   107  				ConflictsWith: []string{"encryption_key"},
   108  			},
   109  
   110  			"storage_custom_endpoint": {
   111  				Type:     schema.TypeString,
   112  				Optional: true,
   113  				DefaultFunc: schema.MultiEnvDefaultFunc([]string{
   114  					"GOOGLE_BACKEND_STORAGE_CUSTOM_ENDPOINT",
   115  					"GOOGLE_STORAGE_CUSTOM_ENDPOINT",
   116  				}, nil),
   117  			},
   118  		},
   119  	}
   120  
   121  	return b
   122  }
   123  
   124  func (b *Backend) configure(ctx context.Context) error {
   125  	if b.storageClient != nil {
   126  		return nil
   127  	}
   128  
   129  	// ctx is a background context with the backend config added.
   130  	// Since no context is passed to remoteClient.Get(), .Lock(), etc. but
   131  	// one is required for calling the GCP API, we're holding on to this
   132  	// context here and re-use it later.
   133  	b.storageContext = ctx
   134  
   135  	data := schema.FromContextBackendConfig(b.storageContext)
   136  
   137  	b.bucketName = data.Get("bucket").(string)
   138  	b.prefix = strings.TrimLeft(data.Get("prefix").(string), "/")
   139  	if b.prefix != "" && !strings.HasSuffix(b.prefix, "/") {
   140  		b.prefix = b.prefix + "/"
   141  	}
   142  
   143  	var opts []option.ClientOption
   144  	var credOptions []option.ClientOption
   145  
   146  	// Add credential source
   147  	var creds string
   148  	var tokenSource oauth2.TokenSource
   149  
   150  	if v, ok := data.GetOk("access_token"); ok {
   151  		tokenSource = oauth2.StaticTokenSource(&oauth2.Token{
   152  			AccessToken: v.(string),
   153  		})
   154  	} else if v, ok := data.GetOk("credentials"); ok {
   155  		creds = v.(string)
   156  	} else if v := os.Getenv("GOOGLE_BACKEND_CREDENTIALS"); v != "" {
   157  		creds = v
   158  	} else {
   159  		creds = os.Getenv("GOOGLE_CREDENTIALS")
   160  	}
   161  
   162  	if tokenSource != nil {
   163  		credOptions = append(credOptions, option.WithTokenSource(tokenSource))
   164  	} else if creds != "" {
   165  
   166  		// to mirror how the provider works, we accept the file path or the contents
   167  		contents, err := backend.ReadPathOrContents(creds)
   168  		if err != nil {
   169  			return fmt.Errorf("Error loading credentials: %s", err)
   170  		}
   171  
   172  		if !json.Valid([]byte(contents)) {
   173  			return fmt.Errorf("the string provided in credentials is neither valid json nor a valid file path")
   174  		}
   175  
   176  		credOptions = append(credOptions, option.WithCredentialsJSON([]byte(contents)))
   177  	}
   178  
   179  	// Service Account Impersonation
   180  	if v, ok := data.GetOk("impersonate_service_account"); ok {
   181  		ServiceAccount := v.(string)
   182  		var delegates []string
   183  
   184  		if v, ok := data.GetOk("impersonate_service_account_delegates"); ok {
   185  			d := v.([]interface{})
   186  			if len(delegates) > 0 {
   187  				delegates = make([]string, 0, len(d))
   188  			}
   189  			for _, delegate := range d {
   190  				delegates = append(delegates, delegate.(string))
   191  			}
   192  		}
   193  
   194  		ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
   195  			TargetPrincipal: ServiceAccount,
   196  			Scopes:          []string{storage.ScopeReadWrite},
   197  			Delegates:       delegates,
   198  		}, credOptions...)
   199  
   200  		if err != nil {
   201  			return err
   202  		}
   203  
   204  		opts = append(opts, option.WithTokenSource(ts))
   205  
   206  	} else {
   207  		opts = append(opts, credOptions...)
   208  	}
   209  
   210  	opts = append(opts, option.WithUserAgent(httpclient.UserAgentString()))
   211  
   212  	// Custom endpoint for storage API
   213  	if storageEndpoint, ok := data.GetOk("storage_custom_endpoint"); ok {
   214  		endpoint := option.WithEndpoint(storageEndpoint.(string))
   215  		opts = append(opts, endpoint)
   216  	}
   217  	client, err := storage.NewClient(b.storageContext, opts...)
   218  	if err != nil {
   219  		return fmt.Errorf("storage.NewClient() failed: %v", err)
   220  	}
   221  
   222  	b.storageClient = client
   223  
   224  	// Customer-supplied encryption
   225  	key := data.Get("encryption_key").(string)
   226  	if key != "" {
   227  		kc, err := backend.ReadPathOrContents(key)
   228  		if err != nil {
   229  			return fmt.Errorf("Error loading encryption key: %s", err)
   230  		}
   231  
   232  		// The GCS client expects a customer supplied encryption key to be
   233  		// passed in as a 32 byte long byte slice. The byte slice is base64
   234  		// encoded before being passed to the API. We take a base64 encoded key
   235  		// to remain consistent with the GCS docs.
   236  		// https://cloud.google.com/storage/docs/encryption#customer-supplied
   237  		// https://github.com/GoogleCloudPlatform/google-cloud-go/blob/def681/storage/storage.go#L1181
   238  		k, err := base64.StdEncoding.DecodeString(kc)
   239  		if err != nil {
   240  			return fmt.Errorf("Error decoding encryption key: %s", err)
   241  		}
   242  		b.encryptionKey = k
   243  	}
   244  
   245  	// Customer-managed encryption
   246  	kmsName := data.Get("kms_encryption_key").(string)
   247  	if kmsName != "" {
   248  		b.kmsKeyName = kmsName
   249  	}
   250  
   251  	return nil
   252  }