github.com/Kevinklinger/open_terraform@v0.11.12-beta1/backend/remote-state/s3/client.go (about)

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