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