github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/backend/remote-state/s3/backend.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package s3 5 6 import ( 7 "encoding/base64" 8 "fmt" 9 "os" 10 "strings" 11 12 "github.com/aws/aws-sdk-go/aws" 13 "github.com/aws/aws-sdk-go/service/dynamodb" 14 "github.com/aws/aws-sdk-go/service/s3" 15 awsbase "github.com/hashicorp/aws-sdk-go-base" 16 "github.com/terramate-io/tf/backend" 17 "github.com/terramate-io/tf/configs/configschema" 18 "github.com/terramate-io/tf/logging" 19 "github.com/terramate-io/tf/tfdiags" 20 "github.com/terramate-io/tf/version" 21 "github.com/zclconf/go-cty/cty" 22 "github.com/zclconf/go-cty/cty/gocty" 23 ) 24 25 func New() backend.Backend { 26 return &Backend{} 27 } 28 29 type Backend struct { 30 s3Client *s3.S3 31 dynClient *dynamodb.DynamoDB 32 33 bucketName string 34 keyName string 35 serverSideEncryption bool 36 customerEncryptionKey []byte 37 acl string 38 kmsKeyID string 39 ddbTable string 40 workspaceKeyPrefix string 41 } 42 43 // ConfigSchema returns a description of the expected configuration 44 // structure for the receiving backend. 45 func (b *Backend) ConfigSchema() *configschema.Block { 46 return &configschema.Block{ 47 Attributes: map[string]*configschema.Attribute{ 48 "bucket": { 49 Type: cty.String, 50 Required: true, 51 Description: "The name of the S3 bucket", 52 }, 53 "key": { 54 Type: cty.String, 55 Required: true, 56 Description: "The path to the state file inside the bucket", 57 }, 58 "region": { 59 Type: cty.String, 60 Optional: true, 61 Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).", 62 }, 63 "dynamodb_endpoint": { 64 Type: cty.String, 65 Optional: true, 66 Description: "A custom endpoint for the DynamoDB API", 67 }, 68 "endpoint": { 69 Type: cty.String, 70 Optional: true, 71 Description: "A custom endpoint for the S3 API", 72 }, 73 "iam_endpoint": { 74 Type: cty.String, 75 Optional: true, 76 Description: "A custom endpoint for the IAM API", 77 }, 78 "sts_endpoint": { 79 Type: cty.String, 80 Optional: true, 81 Description: "A custom endpoint for the STS API", 82 }, 83 "encrypt": { 84 Type: cty.Bool, 85 Optional: true, 86 Description: "Whether to enable server side encryption of the state file", 87 }, 88 "acl": { 89 Type: cty.String, 90 Optional: true, 91 Description: "Canned ACL to be applied to the state file", 92 }, 93 "access_key": { 94 Type: cty.String, 95 Optional: true, 96 Description: "AWS access key", 97 }, 98 "secret_key": { 99 Type: cty.String, 100 Optional: true, 101 Description: "AWS secret key", 102 }, 103 "kms_key_id": { 104 Type: cty.String, 105 Optional: true, 106 Description: "The ARN of a KMS Key to use for encrypting the state", 107 }, 108 "dynamodb_table": { 109 Type: cty.String, 110 Optional: true, 111 Description: "DynamoDB table for state locking and consistency", 112 }, 113 "profile": { 114 Type: cty.String, 115 Optional: true, 116 Description: "AWS profile name", 117 }, 118 "shared_credentials_file": { 119 Type: cty.String, 120 Optional: true, 121 Description: "Path to a shared credentials file", 122 }, 123 "token": { 124 Type: cty.String, 125 Optional: true, 126 Description: "MFA token", 127 }, 128 "skip_credentials_validation": { 129 Type: cty.Bool, 130 Optional: true, 131 Description: "Skip the credentials validation via STS API.", 132 }, 133 "skip_metadata_api_check": { 134 Type: cty.Bool, 135 Optional: true, 136 Description: "Skip the AWS Metadata API check.", 137 }, 138 "skip_region_validation": { 139 Type: cty.Bool, 140 Optional: true, 141 Description: "Skip static validation of region name.", 142 }, 143 "sse_customer_key": { 144 Type: cty.String, 145 Optional: true, 146 Description: "The base64-encoded encryption key to use for server-side encryption with customer-provided keys (SSE-C).", 147 Sensitive: true, 148 }, 149 "role_arn": { 150 Type: cty.String, 151 Optional: true, 152 Description: "The role to be assumed", 153 }, 154 "session_name": { 155 Type: cty.String, 156 Optional: true, 157 Description: "The session name to use when assuming the role.", 158 }, 159 "external_id": { 160 Type: cty.String, 161 Optional: true, 162 Description: "The external ID to use when assuming the role", 163 }, 164 165 "assume_role_duration_seconds": { 166 Type: cty.Number, 167 Optional: true, 168 Description: "Seconds to restrict the assume role session duration.", 169 }, 170 171 "assume_role_policy": { 172 Type: cty.String, 173 Optional: true, 174 Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.", 175 }, 176 177 "assume_role_policy_arns": { 178 Type: cty.Set(cty.String), 179 Optional: true, 180 Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.", 181 }, 182 183 "assume_role_tags": { 184 Type: cty.Map(cty.String), 185 Optional: true, 186 Description: "Assume role session tags.", 187 }, 188 189 "assume_role_transitive_tag_keys": { 190 Type: cty.Set(cty.String), 191 Optional: true, 192 Description: "Assume role session tag keys to pass to any subsequent sessions.", 193 }, 194 195 "workspace_key_prefix": { 196 Type: cty.String, 197 Optional: true, 198 Description: "The prefix applied to the non-default state path inside the bucket.", 199 }, 200 201 "force_path_style": { 202 Type: cty.Bool, 203 Optional: true, 204 Description: "Force s3 to use path style api.", 205 }, 206 207 "max_retries": { 208 Type: cty.Number, 209 Optional: true, 210 Description: "The maximum number of times an AWS API request is retried on retryable failure.", 211 }, 212 }, 213 } 214 } 215 216 // PrepareConfig checks the validity of the values in the given 217 // configuration, and inserts any missing defaults, assuming that its 218 // structure has already been validated per the schema returned by 219 // ConfigSchema. 220 func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { 221 var diags tfdiags.Diagnostics 222 if obj.IsNull() { 223 return obj, diags 224 } 225 226 if val := obj.GetAttr("bucket"); val.IsNull() || val.AsString() == "" { 227 diags = diags.Append(tfdiags.AttributeValue( 228 tfdiags.Error, 229 "Invalid bucket value", 230 `The "bucket" attribute value must not be empty.`, 231 cty.Path{cty.GetAttrStep{Name: "bucket"}}, 232 )) 233 } 234 235 if val := obj.GetAttr("key"); val.IsNull() || val.AsString() == "" { 236 diags = diags.Append(tfdiags.AttributeValue( 237 tfdiags.Error, 238 "Invalid key value", 239 `The "key" attribute value must not be empty.`, 240 cty.Path{cty.GetAttrStep{Name: "key"}}, 241 )) 242 } else if strings.HasPrefix(val.AsString(), "/") || strings.HasSuffix(val.AsString(), "/") { 243 // S3 will strip leading slashes from an object, so while this will 244 // technically be accepted by S3, it will break our workspace hierarchy. 245 // S3 will recognize objects with a trailing slash as a directory 246 // so they should not be valid keys 247 diags = diags.Append(tfdiags.AttributeValue( 248 tfdiags.Error, 249 "Invalid key value", 250 `The "key" attribute value must not start or end with with "/".`, 251 cty.Path{cty.GetAttrStep{Name: "key"}}, 252 )) 253 } 254 255 if val := obj.GetAttr("region"); val.IsNull() || val.AsString() == "" { 256 if os.Getenv("AWS_REGION") == "" && os.Getenv("AWS_DEFAULT_REGION") == "" { 257 diags = diags.Append(tfdiags.AttributeValue( 258 tfdiags.Error, 259 "Missing region value", 260 `The "region" attribute or the "AWS_REGION" or "AWS_DEFAULT_REGION" environment variables must be set.`, 261 cty.Path{cty.GetAttrStep{Name: "region"}}, 262 )) 263 } 264 } 265 266 if val := obj.GetAttr("kms_key_id"); !val.IsNull() && val.AsString() != "" { 267 if val := obj.GetAttr("sse_customer_key"); !val.IsNull() && val.AsString() != "" { 268 diags = diags.Append(tfdiags.AttributeValue( 269 tfdiags.Error, 270 "Invalid encryption configuration", 271 encryptionKeyConflictError, 272 cty.Path{}, 273 )) 274 } else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" { 275 diags = diags.Append(tfdiags.AttributeValue( 276 tfdiags.Error, 277 "Invalid encryption configuration", 278 encryptionKeyConflictEnvVarError, 279 cty.Path{}, 280 )) 281 } 282 283 diags = diags.Append(validateKMSKey(cty.Path{cty.GetAttrStep{Name: "kms_key_id"}}, val.AsString())) 284 } 285 286 if val := obj.GetAttr("workspace_key_prefix"); !val.IsNull() { 287 if v := val.AsString(); strings.HasPrefix(v, "/") || strings.HasSuffix(v, "/") { 288 diags = diags.Append(tfdiags.AttributeValue( 289 tfdiags.Error, 290 "Invalid workspace_key_prefix value", 291 `The "workspace_key_prefix" attribute value must not start with "/".`, 292 cty.Path{cty.GetAttrStep{Name: "workspace_key_prefix"}}, 293 )) 294 } 295 } 296 297 return obj, diags 298 } 299 300 // Configure uses the provided configuration to set configuration fields 301 // within the backend. 302 // 303 // The given configuration is assumed to have already been validated 304 // against the schema returned by ConfigSchema and passed validation 305 // via PrepareConfig. 306 func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { 307 var diags tfdiags.Diagnostics 308 if obj.IsNull() { 309 return diags 310 } 311 312 var region string 313 if v, ok := stringAttrOk(obj, "region"); ok { 314 region = v 315 } 316 317 if region != "" && !boolAttr(obj, "skip_region_validation") { 318 if err := awsbase.ValidateRegion(region); err != nil { 319 diags = diags.Append(tfdiags.AttributeValue( 320 tfdiags.Error, 321 "Invalid region value", 322 err.Error(), 323 cty.Path{cty.GetAttrStep{Name: "region"}}, 324 )) 325 return diags 326 } 327 } 328 329 b.bucketName = stringAttr(obj, "bucket") 330 b.keyName = stringAttr(obj, "key") 331 b.acl = stringAttr(obj, "acl") 332 b.workspaceKeyPrefix = stringAttrDefault(obj, "workspace_key_prefix", "env:") 333 b.serverSideEncryption = boolAttr(obj, "encrypt") 334 b.kmsKeyID = stringAttr(obj, "kms_key_id") 335 b.ddbTable = stringAttr(obj, "dynamodb_table") 336 337 if customerKey, ok := stringAttrOk(obj, "sse_customer_key"); ok { 338 if len(customerKey) != 44 { 339 diags = diags.Append(tfdiags.AttributeValue( 340 tfdiags.Error, 341 "Invalid sse_customer_key value", 342 "sse_customer_key must be 44 characters in length", 343 cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}}, 344 )) 345 } else { 346 var err error 347 if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil { 348 diags = diags.Append(tfdiags.AttributeValue( 349 tfdiags.Error, 350 "Invalid sse_customer_key value", 351 fmt.Sprintf("sse_customer_key must be base64 encoded: %s", err), 352 cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}}, 353 )) 354 } 355 } 356 } else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" { 357 if len(customerKey) != 44 { 358 diags = diags.Append(tfdiags.Sourceless( 359 tfdiags.Error, 360 "Invalid AWS_SSE_CUSTOMER_KEY value", 361 `The environment variable "AWS_SSE_CUSTOMER_KEY" must be 44 characters in length`, 362 )) 363 } else { 364 var err error 365 if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil { 366 diags = diags.Append(tfdiags.Sourceless( 367 tfdiags.Error, 368 "Invalid AWS_SSE_CUSTOMER_KEY value", 369 fmt.Sprintf(`The environment variable "AWS_SSE_CUSTOMER_KEY" must be base64 encoded: %s`, err), 370 )) 371 } 372 } 373 } 374 375 cfg := &awsbase.Config{ 376 AccessKey: stringAttr(obj, "access_key"), 377 AssumeRoleARN: stringAttr(obj, "role_arn"), 378 AssumeRoleDurationSeconds: intAttr(obj, "assume_role_duration_seconds"), 379 AssumeRoleExternalID: stringAttr(obj, "external_id"), 380 AssumeRolePolicy: stringAttr(obj, "assume_role_policy"), 381 AssumeRoleSessionName: stringAttr(obj, "session_name"), 382 CallerDocumentationURL: "https://www.terraform.io/docs/language/settings/backends/s3.html", 383 CallerName: "S3 Backend", 384 CredsFilename: stringAttr(obj, "shared_credentials_file"), 385 DebugLogging: logging.IsDebugOrHigher(), 386 IamEndpoint: stringAttrDefaultEnvVar(obj, "iam_endpoint", "AWS_IAM_ENDPOINT"), 387 MaxRetries: intAttrDefault(obj, "max_retries", 5), 388 Profile: stringAttr(obj, "profile"), 389 Region: stringAttr(obj, "region"), 390 SecretKey: stringAttr(obj, "secret_key"), 391 SkipCredsValidation: boolAttr(obj, "skip_credentials_validation"), 392 SkipMetadataApiCheck: boolAttr(obj, "skip_metadata_api_check"), 393 StsEndpoint: stringAttrDefaultEnvVar(obj, "sts_endpoint", "AWS_STS_ENDPOINT"), 394 Token: stringAttr(obj, "token"), 395 UserAgentProducts: []*awsbase.UserAgentProduct{ 396 {Name: "APN", Version: "1.0"}, 397 {Name: "HashiCorp", Version: "1.0"}, 398 {Name: "Terraform", Version: version.String()}, 399 }, 400 } 401 402 if policyARNSet := obj.GetAttr("assume_role_policy_arns"); !policyARNSet.IsNull() { 403 policyARNSet.ForEachElement(func(key, val cty.Value) (stop bool) { 404 v, ok := stringValueOk(val) 405 if ok { 406 cfg.AssumeRolePolicyARNs = append(cfg.AssumeRolePolicyARNs, v) 407 } 408 return 409 }) 410 } 411 412 if tagMap := obj.GetAttr("assume_role_tags"); !tagMap.IsNull() { 413 cfg.AssumeRoleTags = make(map[string]string, tagMap.LengthInt()) 414 tagMap.ForEachElement(func(key, val cty.Value) (stop bool) { 415 k := stringValue(key) 416 v, ok := stringValueOk(val) 417 if ok { 418 cfg.AssumeRoleTags[k] = v 419 } 420 return 421 }) 422 } 423 424 if transitiveTagKeySet := obj.GetAttr("assume_role_transitive_tag_keys"); !transitiveTagKeySet.IsNull() { 425 transitiveTagKeySet.ForEachElement(func(key, val cty.Value) (stop bool) { 426 v, ok := stringValueOk(val) 427 if ok { 428 cfg.AssumeRoleTransitiveTagKeys = append(cfg.AssumeRoleTransitiveTagKeys, v) 429 } 430 return 431 }) 432 } 433 434 sess, err := awsbase.GetSession(cfg) 435 if err != nil { 436 diags = diags.Append(tfdiags.Sourceless( 437 tfdiags.Error, 438 "Failed to configure AWS client", 439 fmt.Sprintf(`The "S3" backend encountered an unexpected error while creating the AWS client: %s`, err), 440 )) 441 return diags 442 } 443 444 var dynamoConfig aws.Config 445 if v, ok := stringAttrDefaultEnvVarOk(obj, "dynamodb_endpoint", "AWS_DYNAMODB_ENDPOINT"); ok { 446 dynamoConfig.Endpoint = aws.String(v) 447 } 448 b.dynClient = dynamodb.New(sess.Copy(&dynamoConfig)) 449 450 var s3Config aws.Config 451 if v, ok := stringAttrDefaultEnvVarOk(obj, "endpoint", "AWS_S3_ENDPOINT"); ok { 452 s3Config.Endpoint = aws.String(v) 453 } 454 if v, ok := boolAttrOk(obj, "force_path_style"); ok { 455 s3Config.S3ForcePathStyle = aws.Bool(v) 456 } 457 b.s3Client = s3.New(sess.Copy(&s3Config)) 458 459 return diags 460 } 461 462 func stringValue(val cty.Value) string { 463 v, _ := stringValueOk(val) 464 return v 465 } 466 467 func stringValueOk(val cty.Value) (string, bool) { 468 if val.IsNull() { 469 return "", false 470 } else { 471 return val.AsString(), true 472 } 473 } 474 475 func stringAttr(obj cty.Value, name string) string { 476 return stringValue(obj.GetAttr(name)) 477 } 478 479 func stringAttrOk(obj cty.Value, name string) (string, bool) { 480 return stringValueOk(obj.GetAttr(name)) 481 } 482 483 func stringAttrDefault(obj cty.Value, name, def string) string { 484 if v, ok := stringAttrOk(obj, name); !ok { 485 return def 486 } else { 487 return v 488 } 489 } 490 491 func stringAttrDefaultEnvVar(obj cty.Value, name string, envvars ...string) string { 492 if v, ok := stringAttrDefaultEnvVarOk(obj, name, envvars...); !ok { 493 return "" 494 } else { 495 return v 496 } 497 } 498 499 func stringAttrDefaultEnvVarOk(obj cty.Value, name string, envvars ...string) (string, bool) { 500 if v, ok := stringAttrOk(obj, name); !ok { 501 for _, envvar := range envvars { 502 if v := os.Getenv(envvar); v != "" { 503 return v, true 504 } 505 } 506 return "", false 507 } else { 508 return v, true 509 } 510 } 511 512 func boolAttr(obj cty.Value, name string) bool { 513 v, _ := boolAttrOk(obj, name) 514 return v 515 } 516 517 func boolAttrOk(obj cty.Value, name string) (bool, bool) { 518 if val := obj.GetAttr(name); val.IsNull() { 519 return false, false 520 } else { 521 return val.True(), true 522 } 523 } 524 525 func intAttr(obj cty.Value, name string) int { 526 v, _ := intAttrOk(obj, name) 527 return v 528 } 529 530 func intAttrOk(obj cty.Value, name string) (int, bool) { 531 if val := obj.GetAttr(name); val.IsNull() { 532 return 0, false 533 } else { 534 var v int 535 if err := gocty.FromCtyValue(val, &v); err != nil { 536 return 0, false 537 } 538 return v, true 539 } 540 } 541 542 func intAttrDefault(obj cty.Value, name string, def int) int { 543 if v, ok := intAttrOk(obj, name); !ok { 544 return def 545 } else { 546 return v 547 } 548 } 549 550 const encryptionKeyConflictError = `Only one of "kms_key_id" and "sse_customer_key" can be set. 551 552 The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS) 553 while "sse_customer_key" is used for encryption with customer-managed keys (SSE-C). 554 Please choose one or the other.` 555 556 const encryptionKeyConflictEnvVarError = `Only one of "kms_key_id" and the environment variable "AWS_SSE_CUSTOMER_KEY" can be set. 557 558 The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS) 559 while "AWS_SSE_CUSTOMER_KEY" is used for encryption with customer-managed keys (SSE-C). 560 Please choose one or the other.`