github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/backend/remote-state/s3/client.go (about)

     1  package s3
     2  
     3  import (
     4  	"bytes"
     5  	"crypto/md5"
     6  	"encoding/base64"
     7  	"encoding/hex"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"log"
    13  	"time"
    14  
    15  	"github.com/aws/aws-sdk-go/aws"
    16  	"github.com/aws/aws-sdk-go/aws/awserr"
    17  	"github.com/aws/aws-sdk-go/service/dynamodb"
    18  	"github.com/aws/aws-sdk-go/service/s3"
    19  	multierror "github.com/hashicorp/go-multierror"
    20  	uuid "github.com/hashicorp/go-uuid"
    21  	"github.com/hashicorp/terraform/state"
    22  	"github.com/hashicorp/terraform/state/remote"
    23  )
    24  
    25  // Store the last saved serial in dynamo with this suffix for consistency checks.
    26  const (
    27  	s3EncryptionAlgorithm  = "AES256"
    28  	stateIDSuffix          = "-md5"
    29  	s3ErrCodeInternalError = "InternalError"
    30  )
    31  
    32  type RemoteClient struct {
    33  	s3Client              *s3.S3
    34  	dynClient             *dynamodb.DynamoDB
    35  	bucketName            string
    36  	path                  string
    37  	serverSideEncryption  bool
    38  	customerEncryptionKey []byte
    39  	acl                   string
    40  	kmsKeyID              string
    41  	ddbTable              string
    42  }
    43  
    44  var (
    45  	// The amount of time we will retry a state waiting for it to match the
    46  	// expected checksum.
    47  	consistencyRetryTimeout = 10 * time.Second
    48  
    49  	// delay when polling the state
    50  	consistencyRetryPollInterval = 2 * time.Second
    51  )
    52  
    53  // test hook called when checksums don't match
    54  var testChecksumHook func()
    55  
    56  func (c *RemoteClient) Get() (payload *remote.Payload, err error) {
    57  	deadline := time.Now().Add(consistencyRetryTimeout)
    58  
    59  	// If we have a checksum, and the returned payload doesn't match, we retry
    60  	// up until deadline.
    61  	for {
    62  		payload, err = c.get()
    63  		if err != nil {
    64  			return nil, err
    65  		}
    66  
    67  		// If the remote state was manually removed the payload will be nil,
    68  		// but if there's still a digest entry for that state we will still try
    69  		// to compare the MD5 below.
    70  		var digest []byte
    71  		if payload != nil {
    72  			digest = payload.MD5
    73  		}
    74  
    75  		// verify that this state is what we expect
    76  		if expected, err := c.getMD5(); err != nil {
    77  			log.Printf("[WARN] failed to fetch state md5: %s", err)
    78  		} else if len(expected) > 0 && !bytes.Equal(expected, digest) {
    79  			log.Printf("[WARN] state md5 mismatch: expected '%x', got '%x'", expected, digest)
    80  
    81  			if testChecksumHook != nil {
    82  				testChecksumHook()
    83  			}
    84  
    85  			if time.Now().Before(deadline) {
    86  				time.Sleep(consistencyRetryPollInterval)
    87  				log.Println("[INFO] retrying S3 RemoteClient.Get...")
    88  				continue
    89  			}
    90  
    91  			return nil, fmt.Errorf(errBadChecksumFmt, digest)
    92  		}
    93  
    94  		break
    95  	}
    96  
    97  	return payload, err
    98  }
    99  
   100  func (c *RemoteClient) get() (*remote.Payload, error) {
   101  	var output *s3.GetObjectOutput
   102  	var err error
   103  
   104  	input := &s3.GetObjectInput{
   105  		Bucket: &c.bucketName,
   106  		Key:    &c.path,
   107  	}
   108  
   109  	if c.serverSideEncryption && c.customerEncryptionKey != nil {
   110  		input.SetSSECustomerKey(string(c.customerEncryptionKey))
   111  		input.SetSSECustomerAlgorithm(s3EncryptionAlgorithm)
   112  		input.SetSSECustomerKeyMD5(c.getSSECustomerKeyMD5())
   113  	}
   114  
   115  	output, err = c.s3Client.GetObject(input)
   116  
   117  	if err != nil {
   118  		if awserr, ok := err.(awserr.Error); ok {
   119  			switch awserr.Code() {
   120  			case s3.ErrCodeNoSuchBucket:
   121  				return nil, fmt.Errorf(errS3NoSuchBucket, err)
   122  			case s3.ErrCodeNoSuchKey:
   123  				return nil, nil
   124  			}
   125  		}
   126  		return nil, err
   127  	}
   128  
   129  	defer output.Body.Close()
   130  
   131  	buf := bytes.NewBuffer(nil)
   132  	if _, err := io.Copy(buf, output.Body); err != nil {
   133  		return nil, fmt.Errorf("Failed to read remote state: %s", err)
   134  	}
   135  
   136  	sum := md5.Sum(buf.Bytes())
   137  	payload := &remote.Payload{
   138  		Data: buf.Bytes(),
   139  		MD5:  sum[:],
   140  	}
   141  
   142  	// If there was no data, then return nil
   143  	if len(payload.Data) == 0 {
   144  		return nil, nil
   145  	}
   146  
   147  	return payload, nil
   148  }
   149  
   150  func (c *RemoteClient) Put(data []byte) error {
   151  	contentType := "application/json"
   152  	contentLength := int64(len(data))
   153  
   154  	i := &s3.PutObjectInput{
   155  		ContentType:   &contentType,
   156  		ContentLength: &contentLength,
   157  		Body:          bytes.NewReader(data),
   158  		Bucket:        &c.bucketName,
   159  		Key:           &c.path,
   160  	}
   161  
   162  	if c.serverSideEncryption {
   163  		if c.kmsKeyID != "" {
   164  			i.SSEKMSKeyId = &c.kmsKeyID
   165  			i.ServerSideEncryption = aws.String("aws:kms")
   166  		} else if c.customerEncryptionKey != nil {
   167  			i.SetSSECustomerKey(string(c.customerEncryptionKey))
   168  			i.SetSSECustomerAlgorithm(s3EncryptionAlgorithm)
   169  			i.SetSSECustomerKeyMD5(c.getSSECustomerKeyMD5())
   170  		} else {
   171  			i.ServerSideEncryption = aws.String(s3EncryptionAlgorithm)
   172  		}
   173  	}
   174  
   175  	if c.acl != "" {
   176  		i.ACL = aws.String(c.acl)
   177  	}
   178  
   179  	log.Printf("[DEBUG] Uploading remote state to S3: %#v", i)
   180  
   181  	_, err := c.s3Client.PutObject(i)
   182  	if err != nil {
   183  		return fmt.Errorf("failed to upload state: %s", err)
   184  	}
   185  
   186  	sum := md5.Sum(data)
   187  	if err := c.putMD5(sum[:]); err != nil {
   188  		// if this errors out, we unfortunately have to error out altogether,
   189  		// since the next Get will inevitably fail.
   190  		return fmt.Errorf("failed to store state MD5: %s", err)
   191  
   192  	}
   193  
   194  	return nil
   195  }
   196  
   197  func (c *RemoteClient) Delete() error {
   198  	_, err := c.s3Client.DeleteObject(&s3.DeleteObjectInput{
   199  		Bucket: &c.bucketName,
   200  		Key:    &c.path,
   201  	})
   202  
   203  	if err != nil {
   204  		return err
   205  	}
   206  
   207  	if err := c.deleteMD5(); err != nil {
   208  		log.Printf("error deleting state md5: %s", err)
   209  	}
   210  
   211  	return nil
   212  }
   213  
   214  func (c *RemoteClient) Lock(info *state.LockInfo) (string, error) {
   215  	if c.ddbTable == "" {
   216  		return "", nil
   217  	}
   218  
   219  	info.Path = c.lockPath()
   220  
   221  	if info.ID == "" {
   222  		lockID, err := uuid.GenerateUUID()
   223  		if err != nil {
   224  			return "", err
   225  		}
   226  
   227  		info.ID = lockID
   228  	}
   229  
   230  	putParams := &dynamodb.PutItemInput{
   231  		Item: map[string]*dynamodb.AttributeValue{
   232  			"LockID": {S: aws.String(c.lockPath())},
   233  			"Info":   {S: aws.String(string(info.Marshal()))},
   234  		},
   235  		TableName:           aws.String(c.ddbTable),
   236  		ConditionExpression: aws.String("attribute_not_exists(LockID)"),
   237  	}
   238  	_, err := c.dynClient.PutItem(putParams)
   239  
   240  	if err != nil {
   241  		lockInfo, infoErr := c.getLockInfo()
   242  		if infoErr != nil {
   243  			err = multierror.Append(err, infoErr)
   244  		}
   245  
   246  		lockErr := &state.LockError{
   247  			Err:  err,
   248  			Info: lockInfo,
   249  		}
   250  		return "", lockErr
   251  	}
   252  
   253  	return info.ID, nil
   254  }
   255  
   256  func (c *RemoteClient) getMD5() ([]byte, error) {
   257  	if c.ddbTable == "" {
   258  		return nil, nil
   259  	}
   260  
   261  	getParams := &dynamodb.GetItemInput{
   262  		Key: map[string]*dynamodb.AttributeValue{
   263  			"LockID": {S: aws.String(c.lockPath() + stateIDSuffix)},
   264  		},
   265  		ProjectionExpression: aws.String("LockID, Digest"),
   266  		TableName:            aws.String(c.ddbTable),
   267  		ConsistentRead:       aws.Bool(true),
   268  	}
   269  
   270  	resp, err := c.dynClient.GetItem(getParams)
   271  	if err != nil {
   272  		return nil, err
   273  	}
   274  
   275  	var val string
   276  	if v, ok := resp.Item["Digest"]; ok && v.S != nil {
   277  		val = *v.S
   278  	}
   279  
   280  	sum, err := hex.DecodeString(val)
   281  	if err != nil || len(sum) != md5.Size {
   282  		return nil, errors.New("invalid md5")
   283  	}
   284  
   285  	return sum, nil
   286  }
   287  
   288  // store the hash of the state so that clients can check for stale state files.
   289  func (c *RemoteClient) putMD5(sum []byte) error {
   290  	if c.ddbTable == "" {
   291  		return nil
   292  	}
   293  
   294  	if len(sum) != md5.Size {
   295  		return errors.New("invalid payload md5")
   296  	}
   297  
   298  	putParams := &dynamodb.PutItemInput{
   299  		Item: map[string]*dynamodb.AttributeValue{
   300  			"LockID": {S: aws.String(c.lockPath() + stateIDSuffix)},
   301  			"Digest": {S: aws.String(hex.EncodeToString(sum))},
   302  		},
   303  		TableName: aws.String(c.ddbTable),
   304  	}
   305  	_, err := c.dynClient.PutItem(putParams)
   306  	if err != nil {
   307  		log.Printf("[WARN] failed to record state serial in dynamodb: %s", err)
   308  	}
   309  
   310  	return nil
   311  }
   312  
   313  // remove the hash value for a deleted state
   314  func (c *RemoteClient) deleteMD5() error {
   315  	if c.ddbTable == "" {
   316  		return nil
   317  	}
   318  
   319  	params := &dynamodb.DeleteItemInput{
   320  		Key: map[string]*dynamodb.AttributeValue{
   321  			"LockID": {S: aws.String(c.lockPath() + stateIDSuffix)},
   322  		},
   323  		TableName: aws.String(c.ddbTable),
   324  	}
   325  	if _, err := c.dynClient.DeleteItem(params); err != nil {
   326  		return err
   327  	}
   328  	return nil
   329  }
   330  
   331  func (c *RemoteClient) getLockInfo() (*state.LockInfo, error) {
   332  	getParams := &dynamodb.GetItemInput{
   333  		Key: map[string]*dynamodb.AttributeValue{
   334  			"LockID": {S: aws.String(c.lockPath())},
   335  		},
   336  		ProjectionExpression: aws.String("LockID, Info"),
   337  		TableName:            aws.String(c.ddbTable),
   338  		ConsistentRead:       aws.Bool(true),
   339  	}
   340  
   341  	resp, err := c.dynClient.GetItem(getParams)
   342  	if err != nil {
   343  		return nil, err
   344  	}
   345  
   346  	var infoData string
   347  	if v, ok := resp.Item["Info"]; ok && v.S != nil {
   348  		infoData = *v.S
   349  	}
   350  
   351  	lockInfo := &state.LockInfo{}
   352  	err = json.Unmarshal([]byte(infoData), lockInfo)
   353  	if err != nil {
   354  		return nil, err
   355  	}
   356  
   357  	return lockInfo, nil
   358  }
   359  
   360  func (c *RemoteClient) Unlock(id string) error {
   361  	if c.ddbTable == "" {
   362  		return nil
   363  	}
   364  
   365  	lockErr := &state.LockError{}
   366  
   367  	// TODO: store the path and lock ID in separate fields, and have proper
   368  	// projection expression only delete the lock if both match, rather than
   369  	// checking the ID from the info field first.
   370  	lockInfo, err := c.getLockInfo()
   371  	if err != nil {
   372  		lockErr.Err = fmt.Errorf("failed to retrieve lock info: %s", err)
   373  		return lockErr
   374  	}
   375  	lockErr.Info = lockInfo
   376  
   377  	if lockInfo.ID != id {
   378  		lockErr.Err = fmt.Errorf("lock id %q does not match existing lock", id)
   379  		return lockErr
   380  	}
   381  
   382  	params := &dynamodb.DeleteItemInput{
   383  		Key: map[string]*dynamodb.AttributeValue{
   384  			"LockID": {S: aws.String(c.lockPath())},
   385  		},
   386  		TableName: aws.String(c.ddbTable),
   387  	}
   388  	_, err = c.dynClient.DeleteItem(params)
   389  
   390  	if err != nil {
   391  		lockErr.Err = err
   392  		return lockErr
   393  	}
   394  	return nil
   395  }
   396  
   397  func (c *RemoteClient) lockPath() string {
   398  	return fmt.Sprintf("%s/%s", c.bucketName, c.path)
   399  }
   400  
   401  func (c *RemoteClient) getSSECustomerKeyMD5() string {
   402  	b := md5.Sum(c.customerEncryptionKey)
   403  	return base64.StdEncoding.EncodeToString(b[:])
   404  }
   405  
   406  const errBadChecksumFmt = `state data in S3 does not have the expected content.
   407  
   408  This may be caused by unusually long delays in S3 processing a previous state
   409  update.  Please wait for a minute or two and try again. If this problem
   410  persists, and neither S3 nor DynamoDB are experiencing an outage, you may need
   411  to manually verify the remote state and update the Digest value stored in the
   412  DynamoDB table to the following value: %x
   413  `
   414  
   415  const errS3NoSuchBucket = `S3 bucket does not exist.
   416  
   417  The referenced S3 bucket must have been previously created. If the S3 bucket
   418  was created within the last minute, please wait for a minute or two and try
   419  again.
   420  
   421  Error: %s
   422  `