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 `