backend/gcs: Stop using legacy helper/schema

As part of our efforts to remove the large snapshot of the legacy SDK that
we've been using, this replaces the uses of that package with our new
lightweight "backendbase" package, which is a collection of helpers that
can substitute for most of the parts of the legacy SDK that backends tend
to use.

For this backend for now we'll use the "SDK like" helpers which aim to
retain some of the legacy-SDK-specific assumptions that aren't technically
true anymore, such as the idea that null and empty string are equivalent.
Hopefully one day we'll be able to wean this backend off of that more
completely, but this is a pragmatic way to get away from the legacy SDK
without a large rewrite.

This also drew attention to the fact that this backend was previously
relying on the weird context.Context value that the SDK was passing in
to the configure method, even though that context isn't connected up to
anything particularly useful. In the long run we should change the backend
APIs to pass context.Context to each method that might make network
requests, but for now we're using context.TODO so that we can more easily
find these cases and update them later once there's a real context to use.
pull/35035/head
Martin Atkins 2 years ago
parent 2b6af4a722
commit 5868f99413

@ -13,22 +13,25 @@ import (
"strings"
"cloud.google.com/go/storage"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/legacy/helper/schema"
"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.
// Input(), Validate() and Configure() are implemented by embedding *schema.Backend.
// State(), DeleteState() and States() are implemented explicitly.
// Schema() and PrepareConfig() are implemented by embedding backendbase.Base.
// Configure(), State(), DeleteState() and States() are implemented explicitly.
type Backend struct {
*schema.Backend
backendbase.Base
storageClient *storage.Client
storageContext context.Context
storageClient *storage.Client
bucketName string
prefix string
@ -38,104 +41,116 @@ type Backend struct {
}
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",
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,
},
},
},
"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",
},
"impersonate_service_account": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_BACKEND_IMPERSONATE_SERVICE_ACCOUNT",
"GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
}, nil),
Description: "The service account to impersonate for all Google API Calls",
},
"impersonate_service_account_delegates": {
Type: schema.TypeList,
Optional: true,
Description: "The delegation chain for the impersonated service account",
Elem: &schema.Schema{Type: schema.TypeString},
},
"encryption_key": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_ENCRYPTION_KEY",
}, nil),
Description: "A 32 byte base64 encoded 'customer supplied encryption key' used when reading and writing state files in the bucket.",
ConflictsWith: []string{"kms_encryption_key"},
},
"kms_encryption_key": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_KMS_ENCRYPTION_KEY",
}, nil),
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}}'.",
ConflictsWith: []string{"encryption_key"},
},
"storage_custom_endpoint": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
"GOOGLE_BACKEND_STORAGE_CUSTOM_ENDPOINT",
"GOOGLE_STORAGE_CUSTOM_ENDPOINT",
}, nil),
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",
},
},
},
},
}
return b
}
func (b *Backend) configure(ctx context.Context) error {
func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
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
// TODO: Update the Backend API to pass the real context.Context from
// the running command.
ctx := context.TODO()
data := backendbase.NewSDKLikeData(configVal)
data := schema.FromContextBackendConfig(b.storageContext)
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"),
)
}
b.bucketName = data.Get("bucket").(string)
b.prefix = strings.TrimLeft(data.Get("prefix").(string), "/")
b.bucketName = data.String("bucket")
b.prefix = strings.TrimLeft(data.String("prefix"), "/")
if b.prefix != "" && !strings.HasSuffix(b.prefix, "/") {
b.prefix = b.prefix + "/"
}
@ -147,12 +162,12 @@ func (b *Backend) configure(ctx context.Context) error {
var creds string
var tokenSource oauth2.TokenSource
if v, ok := data.GetOk("access_token"); ok {
if v := data.String("access_token"); v != "" {
tokenSource = oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: v.(string),
AccessToken: v,
})
} else if v, ok := data.GetOk("credentials"); ok {
creds = v.(string)
} else if v := data.String("credentials"); v != "" {
creds = v
} else if v := os.Getenv("GOOGLE_BACKEND_CREDENTIALS"); v != "" {
creds = v
} else {
@ -166,28 +181,36 @@ func (b *Backend) configure(ctx context.Context) error {
// to mirror how the provider works, we accept the file path or the contents
contents, err := readPathOrContents(creds)
if err != nil {
return fmt.Errorf("Error loading credentials: %s", err)
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("Error loading credentials: %s", err),
)
}
if !json.Valid([]byte(contents)) {
return fmt.Errorf("the string provided in credentials is neither valid json nor a valid file path")
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, ok := data.GetOk("impersonate_service_account"); ok {
ServiceAccount := v.(string)
if v := data.String("impersonate_service_account"); v != "" {
ServiceAccount := v
var delegates []string
if v, ok := data.GetOk("impersonate_service_account_delegates"); ok {
d := v.([]interface{})
if len(delegates) > 0 {
delegates = make([]string, 0, len(d))
}
for _, delegate := range d {
delegates = append(delegates, delegate.(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())
}
}
@ -198,7 +221,7 @@ func (b *Backend) configure(ctx context.Context) error {
}, credOptions...)
if err != nil {
return err
return backendbase.ErrorAsDiagnostics(err)
}
opts = append(opts, option.WithTokenSource(ts))
@ -210,23 +233,27 @@ func (b *Backend) configure(ctx context.Context) error {
opts = append(opts, option.WithUserAgent(httpclient.UserAgentString()))
// Custom endpoint for storage API
if storageEndpoint, ok := data.GetOk("storage_custom_endpoint"); ok {
endpoint := option.WithEndpoint(storageEndpoint.(string))
if storageEndpoint := data.String("storage_custom_endpoint"); storageEndpoint != "" {
endpoint := option.WithEndpoint(storageEndpoint)
opts = append(opts, endpoint)
}
client, err := storage.NewClient(b.storageContext, opts...)
client, err := storage.NewClient(ctx, opts...)
if err != nil {
return fmt.Errorf("storage.NewClient() failed: %v", err)
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("storage.NewClient() failed: %v", err),
)
}
b.storageClient = client
// Customer-supplied encryption
key := data.Get("encryption_key").(string)
key := data.String("encryption_key")
if key != "" {
kc, err := readPathOrContents(key)
if err != nil {
return fmt.Errorf("Error loading encryption key: %s", err)
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("Error loading encryption key: %s", err),
)
}
// The GCS client expects a customer supplied encryption key to be
@ -237,13 +264,15 @@ func (b *Backend) configure(ctx context.Context) error {
// 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)
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("Error decoding encryption key: %s", err),
)
}
b.encryptionKey = k
}
// Customer-managed encryption
kmsName := data.Get("kms_encryption_key").(string)
kmsName := data.String("kms_encryption_key")
if kmsName != "" {
b.kmsKeyName = kmsName
}

@ -4,6 +4,7 @@
package gcs
import (
"context"
"fmt"
"path"
"sort"
@ -26,10 +27,12 @@ const (
// Workspaces returns a list of names for the workspaces found on GCS. The default
// state is always returned as the first element in the slice.
func (b *Backend) Workspaces() ([]string, error) {
ctx := context.TODO()
states := []string{backend.DefaultStateName}
bucket := b.storageClient.Bucket(b.bucketName)
objs := bucket.Objects(b.storageContext, &storage.Query{
objs := bucket.Objects(ctx, &storage.Query{
Delimiter: "/",
Prefix: b.prefix,
})
@ -78,13 +81,12 @@ func (b *Backend) client(name string) (*remoteClient, error) {
}
return &remoteClient{
storageContext: b.storageContext,
storageClient: b.storageClient,
bucketName: b.bucketName,
stateFilePath: b.stateFile(name),
lockFilePath: b.lockFile(name),
encryptionKey: b.encryptionKey,
kmsKeyName: b.kmsKeyName,
storageClient: b.storageClient,
bucketName: b.bucketName,
stateFilePath: b.stateFile(name),
lockFilePath: b.lockFile(name),
encryptionKey: b.encryptionKey,
kmsKeyName: b.kmsKeyName,
}, nil
}

@ -214,6 +214,7 @@ func TestBackendWithCustomerManagedKMSEncryption(t *testing.T) {
// setupBackend returns a new GCS backend.
func setupBackend(t *testing.T, bucket, prefix, key, kmsName string) backend.Backend {
t.Helper()
ctx := context.Background()
projectID := os.Getenv("GOOGLE_PROJECT")
if projectID == "" || os.Getenv("TF_ACC") == "" {
@ -240,7 +241,7 @@ func setupBackend(t *testing.T, bucket, prefix, key, kmsName string) backend.Bac
// create the bucket if it doesn't exist
bkt := be.storageClient.Bucket(bucket)
_, err := bkt.Attrs(be.storageContext)
_, err := bkt.Attrs(ctx)
if err != nil {
if err != storage.ErrBucketNotExist {
t.Fatal(err)
@ -249,7 +250,7 @@ func setupBackend(t *testing.T, bucket, prefix, key, kmsName string) backend.Bac
attrs := &storage.BucketAttrs{
Location: os.Getenv("GOOGLE_REGION"),
}
err := bkt.Create(be.storageContext, projectID, attrs)
err := bkt.Create(ctx, projectID, attrs)
if err != nil {
t.Fatal(err)
}
@ -379,7 +380,7 @@ func teardownBackend(t *testing.T, be backend.Backend, prefix string) {
if !ok {
t.Fatalf("be is a %T, want a *gcsBackend", be)
}
ctx := gcsBE.storageContext
ctx := context.Background()
bucket := gcsBE.storageClient.Bucket(gcsBE.bucketName)
objs := bucket.Objects(ctx, nil)

@ -4,6 +4,7 @@
package gcs
import (
"context"
"encoding/json"
"errors"
"fmt"
@ -11,7 +12,6 @@ import (
"strconv"
"cloud.google.com/go/storage"
"golang.org/x/net/context"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/states/statemgr"
@ -21,17 +21,17 @@ import (
// blobs representing state.
// Implements "state/remote".ClientLocker
type remoteClient struct {
storageContext context.Context
storageClient *storage.Client
bucketName string
stateFilePath string
lockFilePath string
encryptionKey []byte
kmsKeyName string
storageClient *storage.Client
bucketName string
stateFilePath string
lockFilePath string
encryptionKey []byte
kmsKeyName string
}
func (c *remoteClient) Get() (payload *remote.Payload, err error) {
stateFileReader, err := c.stateFile().NewReader(c.storageContext)
ctx := context.TODO()
stateFileReader, err := c.stateFile().NewReader(ctx)
if err != nil {
if err == storage.ErrObjectNotExist {
return nil, nil
@ -46,7 +46,7 @@ func (c *remoteClient) Get() (payload *remote.Payload, err error) {
return nil, fmt.Errorf("Failed to read state file from %v: %v", c.stateFileURL(), err)
}
stateFileAttrs, err := c.stateFile().Attrs(c.storageContext)
stateFileAttrs, err := c.stateFile().Attrs(ctx)
if err != nil {
return nil, fmt.Errorf("Failed to read state file attrs from %v: %v", c.stateFileURL(), err)
}
@ -60,8 +60,9 @@ func (c *remoteClient) Get() (payload *remote.Payload, err error) {
}
func (c *remoteClient) Put(data []byte) error {
ctx := context.TODO()
err := func() error {
stateFileWriter := c.stateFile().NewWriter(c.storageContext)
stateFileWriter := c.stateFile().NewWriter(ctx)
if len(c.kmsKeyName) > 0 {
stateFileWriter.KMSKeyName = c.kmsKeyName
}
@ -78,7 +79,8 @@ func (c *remoteClient) Put(data []byte) error {
}
func (c *remoteClient) Delete() error {
if err := c.stateFile().Delete(c.storageContext); err != nil {
ctx := context.TODO()
if err := c.stateFile().Delete(ctx); err != nil {
return fmt.Errorf("Failed to delete state file %v: %v", c.stateFileURL(), err)
}
@ -88,6 +90,8 @@ func (c *remoteClient) Delete() error {
// Lock writes to a lock file, ensuring file creation. Returns the generation
// number, which must be passed to Unlock().
func (c *remoteClient) Lock(info *statemgr.LockInfo) (string, error) {
ctx := context.TODO()
// update the path we're using
// we can't set the ID until the info is written
info.Path = c.lockFileURL()
@ -98,7 +102,7 @@ func (c *remoteClient) Lock(info *statemgr.LockInfo) (string, error) {
}
lockFile := c.lockFile()
w := lockFile.If(storage.Conditions{DoesNotExist: true}).NewWriter(c.storageContext)
w := lockFile.If(storage.Conditions{DoesNotExist: true}).NewWriter(ctx)
err = func() error {
if _, err := w.Write(infoJson); err != nil {
return err
@ -116,12 +120,14 @@ func (c *remoteClient) Lock(info *statemgr.LockInfo) (string, error) {
}
func (c *remoteClient) Unlock(id string) error {
ctx := context.TODO()
gen, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return fmt.Errorf("Lock ID should be numerical value, got '%s'", id)
}
if err := c.lockFile().If(storage.Conditions{GenerationMatch: gen}).Delete(c.storageContext); err != nil {
if err := c.lockFile().If(storage.Conditions{GenerationMatch: gen}).Delete(ctx); err != nil {
return c.lockError(err)
}
@ -145,7 +151,9 @@ func (c *remoteClient) lockError(err error) *statemgr.LockError {
// lockInfo reads the lock file, parses its contents and returns the parsed
// LockInfo struct.
func (c *remoteClient) lockInfo() (*statemgr.LockInfo, error) {
r, err := c.lockFile().NewReader(c.storageContext)
ctx := context.TODO()
r, err := c.lockFile().NewReader(ctx)
if err != nil {
return nil, err
}
@ -164,7 +172,7 @@ func (c *remoteClient) lockInfo() (*statemgr.LockInfo, error) {
// We use the Generation as the ID, so overwrite the ID in the json.
// This can't be written into the Info, since the generation isn't known
// until it's written.
attrs, err := c.lockFile().Attrs(c.storageContext)
attrs, err := c.lockFile().Attrs(ctx)
if err != nil {
return nil, err
}

@ -6,9 +6,8 @@ require (
cloud.google.com/go/kms v1.15.0
cloud.google.com/go/storage v1.30.1
github.com/hashicorp/terraform v0.0.0-00010101000000-000000000000
github.com/hashicorp/terraform/internal/legacy v0.0.0-00010101000000-000000000000
github.com/mitchellh/go-homedir v1.1.0
golang.org/x/net v0.22.0
github.com/zclconf/go-cty v1.14.3
golang.org/x/oauth2 v0.18.0
google.golang.org/api v0.126.0
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d
@ -38,17 +37,14 @@ require (
github.com/hashicorp/terraform-registry-address v0.2.3 // indirect
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/zclconf/go-cty v1.14.3 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect

@ -223,18 +223,12 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=

Loading…
Cancel
Save