| package s3 |
| |
| import ( |
| "context" |
| "encoding/base64" |
| "errors" |
| "fmt" |
| "strings" |
| |
| "github.com/aws/aws-sdk-go/aws" |
| "github.com/aws/aws-sdk-go/service/dynamodb" |
| "github.com/aws/aws-sdk-go/service/s3" |
| awsbase "github.com/hashicorp/aws-sdk-go-base" |
| "github.com/hashicorp/terraform/internal/backend" |
| "github.com/hashicorp/terraform/internal/legacy/helper/schema" |
| "github.com/hashicorp/terraform/internal/logging" |
| "github.com/hashicorp/terraform/version" |
| ) |
| |
| // New creates a new backend for S3 remote state. |
| func New() backend.Backend { |
| s := &schema.Backend{ |
| Schema: map[string]*schema.Schema{ |
| "bucket": { |
| Type: schema.TypeString, |
| Required: true, |
| Description: "The name of the S3 bucket", |
| }, |
| |
| "key": { |
| Type: schema.TypeString, |
| Required: true, |
| Description: "The path to the state file inside the bucket", |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| // s3 will strip leading slashes from an object, so while this will |
| // technically be accepted by s3, it will break our workspace hierarchy. |
| if strings.HasPrefix(v.(string), "/") { |
| return nil, []error{errors.New("key must not start with '/'")} |
| } |
| // s3 will recognize objects with a trailing slash as a directory |
| // so they should not be valid keys |
| if strings.HasSuffix(v.(string), "/") { |
| return nil, []error{errors.New("key must not end with '/'")} |
| } |
| return nil, nil |
| }, |
| }, |
| |
| "region": { |
| Type: schema.TypeString, |
| Required: true, |
| Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).", |
| DefaultFunc: schema.MultiEnvDefaultFunc([]string{ |
| "AWS_REGION", |
| "AWS_DEFAULT_REGION", |
| }, nil), |
| }, |
| |
| "dynamodb_endpoint": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "A custom endpoint for the DynamoDB API", |
| DefaultFunc: schema.EnvDefaultFunc("AWS_DYNAMODB_ENDPOINT", ""), |
| }, |
| |
| "endpoint": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "A custom endpoint for the S3 API", |
| DefaultFunc: schema.EnvDefaultFunc("AWS_S3_ENDPOINT", ""), |
| }, |
| |
| "iam_endpoint": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "A custom endpoint for the IAM API", |
| DefaultFunc: schema.EnvDefaultFunc("AWS_IAM_ENDPOINT", ""), |
| }, |
| |
| "sts_endpoint": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "A custom endpoint for the STS API", |
| DefaultFunc: schema.EnvDefaultFunc("AWS_STS_ENDPOINT", ""), |
| }, |
| |
| "encrypt": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Whether to enable server side encryption of the state file", |
| Default: false, |
| }, |
| |
| "acl": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "Canned ACL to be applied to the state file", |
| Default: "", |
| }, |
| |
| "access_key": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "AWS access key", |
| Default: "", |
| }, |
| |
| "secret_key": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "AWS secret key", |
| Default: "", |
| }, |
| |
| "kms_key_id": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The ARN of a KMS Key to use for encrypting the state", |
| Default: "", |
| }, |
| |
| "dynamodb_table": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "DynamoDB table for state locking and consistency", |
| Default: "", |
| }, |
| |
| "profile": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "AWS profile name", |
| Default: "", |
| }, |
| |
| "shared_credentials_file": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "Path to a shared credentials file", |
| Default: "", |
| }, |
| |
| "token": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "MFA token", |
| Default: "", |
| }, |
| |
| "skip_credentials_validation": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Skip the credentials validation via STS API.", |
| Default: false, |
| }, |
| |
| "skip_region_validation": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Skip static validation of region name.", |
| Default: false, |
| }, |
| |
| "skip_metadata_api_check": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Skip the AWS Metadata API check.", |
| Default: false, |
| }, |
| |
| "sse_customer_key": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The base64-encoded encryption key to use for server-side encryption with customer-provided keys (SSE-C).", |
| DefaultFunc: schema.EnvDefaultFunc("AWS_SSE_CUSTOMER_KEY", ""), |
| Sensitive: true, |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| key := v.(string) |
| if key != "" && len(key) != 44 { |
| return nil, []error{errors.New("sse_customer_key must be 44 characters in length (256 bits, base64 encoded)")} |
| } |
| return nil, nil |
| }, |
| }, |
| |
| "role_arn": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The role to be assumed", |
| Default: "", |
| }, |
| |
| "session_name": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The session name to use when assuming the role.", |
| Default: "", |
| }, |
| |
| "external_id": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The external ID to use when assuming the role", |
| Default: "", |
| }, |
| |
| "assume_role_duration_seconds": { |
| Type: schema.TypeInt, |
| Optional: true, |
| Description: "Seconds to restrict the assume role session duration.", |
| }, |
| |
| "assume_role_policy": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.", |
| Default: "", |
| }, |
| |
| "assume_role_policy_arns": { |
| Type: schema.TypeSet, |
| Optional: true, |
| Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.", |
| Elem: &schema.Schema{Type: schema.TypeString}, |
| }, |
| |
| "assume_role_tags": { |
| Type: schema.TypeMap, |
| Optional: true, |
| Description: "Assume role session tags.", |
| Elem: &schema.Schema{Type: schema.TypeString}, |
| }, |
| |
| "assume_role_transitive_tag_keys": { |
| Type: schema.TypeSet, |
| Optional: true, |
| Description: "Assume role session tag keys to pass to any subsequent sessions.", |
| Elem: &schema.Schema{Type: schema.TypeString}, |
| }, |
| |
| "workspace_key_prefix": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The prefix applied to the non-default state path inside the bucket.", |
| Default: "env:", |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| prefix := v.(string) |
| if strings.HasPrefix(prefix, "/") || strings.HasSuffix(prefix, "/") { |
| return nil, []error{errors.New("workspace_key_prefix must not start or end with '/'")} |
| } |
| return nil, nil |
| }, |
| }, |
| |
| "force_path_style": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Force s3 to use path style api.", |
| Default: false, |
| }, |
| |
| "max_retries": { |
| Type: schema.TypeInt, |
| Optional: true, |
| Description: "The maximum number of times an AWS API request is retried on retryable failure.", |
| Default: 5, |
| }, |
| }, |
| } |
| |
| result := &Backend{Backend: s} |
| result.Backend.ConfigureFunc = result.configure |
| return result |
| } |
| |
| type Backend struct { |
| *schema.Backend |
| |
| // The fields below are set from configure |
| s3Client *s3.S3 |
| dynClient *dynamodb.DynamoDB |
| |
| bucketName string |
| keyName string |
| serverSideEncryption bool |
| customerEncryptionKey []byte |
| acl string |
| kmsKeyID string |
| ddbTable string |
| workspaceKeyPrefix string |
| } |
| |
| func (b *Backend) configure(ctx context.Context) error { |
| if b.s3Client != nil { |
| return nil |
| } |
| |
| // Grab the resource data |
| data := schema.FromContextBackendConfig(ctx) |
| |
| if !data.Get("skip_region_validation").(bool) { |
| if err := awsbase.ValidateRegion(data.Get("region").(string)); err != nil { |
| return err |
| } |
| } |
| |
| b.bucketName = data.Get("bucket").(string) |
| b.keyName = data.Get("key").(string) |
| b.acl = data.Get("acl").(string) |
| b.workspaceKeyPrefix = data.Get("workspace_key_prefix").(string) |
| b.serverSideEncryption = data.Get("encrypt").(bool) |
| b.kmsKeyID = data.Get("kms_key_id").(string) |
| b.ddbTable = data.Get("dynamodb_table").(string) |
| |
| customerKeyString := data.Get("sse_customer_key").(string) |
| if customerKeyString != "" { |
| if b.kmsKeyID != "" { |
| return errors.New(encryptionKeyConflictError) |
| } |
| |
| var err error |
| b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKeyString) |
| if err != nil { |
| return fmt.Errorf("Failed to decode sse_customer_key: %s", err.Error()) |
| } |
| } |
| |
| cfg := &awsbase.Config{ |
| AccessKey: data.Get("access_key").(string), |
| AssumeRoleARN: data.Get("role_arn").(string), |
| AssumeRoleDurationSeconds: data.Get("assume_role_duration_seconds").(int), |
| AssumeRoleExternalID: data.Get("external_id").(string), |
| AssumeRolePolicy: data.Get("assume_role_policy").(string), |
| AssumeRoleSessionName: data.Get("session_name").(string), |
| CallerDocumentationURL: "https://www.terraform.io/docs/language/settings/backends/s3.html", |
| CallerName: "S3 Backend", |
| CredsFilename: data.Get("shared_credentials_file").(string), |
| DebugLogging: logging.IsDebugOrHigher(), |
| IamEndpoint: data.Get("iam_endpoint").(string), |
| MaxRetries: data.Get("max_retries").(int), |
| Profile: data.Get("profile").(string), |
| Region: data.Get("region").(string), |
| SecretKey: data.Get("secret_key").(string), |
| SkipCredsValidation: data.Get("skip_credentials_validation").(bool), |
| SkipMetadataApiCheck: data.Get("skip_metadata_api_check").(bool), |
| StsEndpoint: data.Get("sts_endpoint").(string), |
| Token: data.Get("token").(string), |
| UserAgentProducts: []*awsbase.UserAgentProduct{ |
| {Name: "APN", Version: "1.0"}, |
| {Name: "HashiCorp", Version: "1.0"}, |
| {Name: "Terraform", Version: version.String()}, |
| }, |
| } |
| |
| if policyARNSet := data.Get("assume_role_policy_arns").(*schema.Set); policyARNSet.Len() > 0 { |
| for _, policyARNRaw := range policyARNSet.List() { |
| policyARN, ok := policyARNRaw.(string) |
| |
| if !ok { |
| continue |
| } |
| |
| cfg.AssumeRolePolicyARNs = append(cfg.AssumeRolePolicyARNs, policyARN) |
| } |
| } |
| |
| if tagMap := data.Get("assume_role_tags").(map[string]interface{}); len(tagMap) > 0 { |
| cfg.AssumeRoleTags = make(map[string]string) |
| |
| for k, vRaw := range tagMap { |
| v, ok := vRaw.(string) |
| |
| if !ok { |
| continue |
| } |
| |
| cfg.AssumeRoleTags[k] = v |
| } |
| } |
| |
| if transitiveTagKeySet := data.Get("assume_role_transitive_tag_keys").(*schema.Set); transitiveTagKeySet.Len() > 0 { |
| for _, transitiveTagKeyRaw := range transitiveTagKeySet.List() { |
| transitiveTagKey, ok := transitiveTagKeyRaw.(string) |
| |
| if !ok { |
| continue |
| } |
| |
| cfg.AssumeRoleTransitiveTagKeys = append(cfg.AssumeRoleTransitiveTagKeys, transitiveTagKey) |
| } |
| } |
| |
| sess, err := awsbase.GetSession(cfg) |
| if err != nil { |
| return fmt.Errorf("error configuring S3 Backend: %w", err) |
| } |
| |
| b.dynClient = dynamodb.New(sess.Copy(&aws.Config{ |
| Endpoint: aws.String(data.Get("dynamodb_endpoint").(string)), |
| })) |
| b.s3Client = s3.New(sess.Copy(&aws.Config{ |
| Endpoint: aws.String(data.Get("endpoint").(string)), |
| S3ForcePathStyle: aws.Bool(data.Get("force_path_style").(bool)), |
| })) |
| |
| return nil |
| } |
| |
| const encryptionKeyConflictError = `Cannot have both kms_key_id and sse_customer_key set. |
| |
| The kms_key_id is used for encryption with KMS-Managed Keys (SSE-KMS) |
| while sse_customer_key is used for encryption with customer-managed keys (SSE-C). |
| Please choose one or the other.` |