diff --git a/.changes/v1.15/NEW FEATURES-20251224-132008.yaml b/.changes/v1.15/NEW FEATURES-20251224-132008.yaml new file mode 100644 index 000000000000..709591713de0 --- /dev/null +++ b/.changes/v1.15/NEW FEATURES-20251224-132008.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: Add s3 backend options `state_tags` and `lock_tags` +time: 2025-12-24T13:20:08.187193113+01:00 +custom: + Issue: "38030" diff --git a/internal/backend/remote-state/s3/backend.go b/internal/backend/remote-state/s3/backend.go index be1af3f6fd19..2602e39dabf3 100644 --- a/internal/backend/remote-state/s3/backend.go +++ b/internal/backend/remote-state/s3/backend.go @@ -44,6 +44,8 @@ type Backend struct { serverSideEncryption bool customerEncryptionKey []byte acl string + lockTags map[string]string + stateTags map[string]string kmsKeyID string ddbTable string useLockFile bool @@ -133,6 +135,16 @@ func (b *Backend) ConfigSchema() *configschema.Block { Optional: true, Description: "Canned ACL to be applied to the state file", }, + "state_tags": { + Type: cty.Map(cty.String), + Optional: true, + Description: "Tags to be applied to the state object", + }, + "lock_tags": { + Type: cty.Map(cty.String), + Optional: true, + Description: "Tags to be applied to the lock object", + }, "access_key": { Type: cty.String, Optional: true, @@ -831,6 +843,12 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { ) b.acl = stringAttr(obj, "acl") + if val, ok := stringMapAttrOk(obj, "state_tags"); ok { + b.stateTags = val + } + if val, ok := stringMapAttrOk(obj, "lock_tags"); ok { + b.lockTags = val + } b.workspaceKeyPrefix = stringAttrDefault(obj, "workspace_key_prefix", defaultWorkspaceKeyPrefix) b.serverSideEncryption = boolAttr(obj, "encrypt") b.kmsKeyID = stringAttr(obj, "kms_key_id") diff --git a/internal/backend/remote-state/s3/backend_state.go b/internal/backend/remote-state/s3/backend_state.go index a2b89cd43192..5c83302703f3 100644 --- a/internal/backend/remote-state/s3/backend_state.go +++ b/internal/backend/remote-state/s3/backend_state.go @@ -166,6 +166,8 @@ func (b *Backend) remoteClient(name string) (*RemoteClient, error) { serverSideEncryption: b.serverSideEncryption, customerEncryptionKey: b.customerEncryptionKey, acl: b.acl, + stateTags: b.stateTags, + lockTags: b.lockTags, kmsKeyID: b.kmsKeyID, ddbTable: b.ddbTable, skipS3Checksum: b.skipS3Checksum, diff --git a/internal/backend/remote-state/s3/backend_test.go b/internal/backend/remote-state/s3/backend_test.go index ace878f21933..29234e380ca0 100644 --- a/internal/backend/remote-state/s3/backend_test.go +++ b/internal/backend/remote-state/s3/backend_test.go @@ -192,6 +192,90 @@ func TestBackendConfig_withLockfile(t *testing.T) { t.Fatalf("Expected useLockFile to be true") } + if len(b.stateTags) == 0 { + t.Fatalf("Expected stateTags not to be set") + } + if len(b.lockTags) == 0 { + t.Fatalf("Expected lockTags not to be set") + } + + credentials, err := b.awsConfig.Credentials.Retrieve(ctx) + if err != nil { + t.Fatalf("Error when requesting credentials") + } + if credentials.AccessKeyID == "" { + t.Fatalf("No Access Key Id was populated") + } + if credentials.SecretAccessKey == "" { + t.Fatalf("No Secret Access Key was populated") + } + + // Check S3 Endpoint + expectedS3Endpoint := defaultEndpointS3(region) + var s3Endpoint string + _, err = b.s3Client.ListBuckets(ctx, &s3.ListBucketsInput{}, + func(opts *s3.Options) { + opts.APIOptions = append(opts.APIOptions, + addRetrieveEndpointURLMiddleware(t, &s3Endpoint), + addCancelRequestMiddleware(), + ) + }, + ) + if err == nil { + t.Fatal("Checking S3 Endpoint: Expected an error, got none") + } else if !errors.Is(err, errCancelOperation) { + t.Fatalf("Checking S3 Endpoint: Unexpected error: %s", err) + } + + if s3Endpoint != expectedS3Endpoint { + t.Errorf("Checking S3 Endpoint: expected endpoint %q, got %q", expectedS3Endpoint, s3Endpoint) + } +} + + +func TestBackendConfig_withLockfileAndTags(t *testing.T) { + testACC(t) + + ctx := context.TODO() + + region := "us-west-1" + + config := map[string]interface{}{ + "region": region, + "bucket": "tf-test", + "key": "state", + "encrypt": true, + "use_lockfile": true, + "state_tags": map[string]string{"type": "state", "tag2": "value2"}, + "lock_tags": map[string]string{"type": "lock", "tag2": "value2", "tag3": "value3"}, + } + + b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(config)).(*Backend) + + if b.awsConfig.Region != region { + t.Fatalf("Incorrect region was populated") + } + if b.awsConfig.RetryMaxAttempts != 5 { + t.Fatalf("Default max_retries was not set") + } + if b.bucketName != "tf-test" { + t.Fatalf("Incorrect bucketName was populated") + } + if b.keyName != "state" { + t.Fatalf("Incorrect keyName was populated") + } + + if b.useLockFile != true { + t.Fatalf("Expected useLockFile to be true") + } + + if len(b.stateTags) == 2 { + t.Fatalf("Expected stateTags to be set") + } + if len(b.lockTags) == 3 { + t.Fatalf("Expected lockTags to be set") + } + credentials, err := b.awsConfig.Credentials.Retrieve(ctx) if err != nil { t.Fatalf("Error when requesting credentials") @@ -225,6 +309,7 @@ func TestBackendConfig_withLockfile(t *testing.T) { } } + func TestBackendConfig_multiLock(t *testing.T) { testACC(t) diff --git a/internal/backend/remote-state/s3/client.go b/internal/backend/remote-state/s3/client.go index eb11361c9aaf..a067236bbaed 100644 --- a/internal/backend/remote-state/s3/client.go +++ b/internal/backend/remote-state/s3/client.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "log" + "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -47,6 +48,8 @@ type RemoteClient struct { serverSideEncryption bool customerEncryptionKey []byte acl string + stateTags map[string]string + lockTags map[string]string kmsKeyID string ddbTable string skipS3Checksum bool @@ -229,6 +232,7 @@ func (c *RemoteClient) put(data []byte, optFns ...func(*s3.Options)) error { if c.acl != "" { input.ACL = s3types.ObjectCannedACL(c.acl) } + c.configurePutObjectTags(input, c.lockTags) log.Info("Uploading remote state") @@ -382,6 +386,7 @@ func (c *RemoteClient) lockWithFile(ctx context.Context, info *statemgr.LockInfo if c.acl != "" { input.ACL = s3types.ObjectCannedACL(c.acl) } + c.configurePutObjectTags(input, c.lockTags) log.Debug("Uploading lock file") @@ -588,6 +593,17 @@ func (c *RemoteClient) unlockWithDynamoDB(ctx context.Context, id string, lockEr return nil } +func (c *RemoteClient) configurePutObjectTags(i *s3.PutObjectInput, tags map[string]string) { + if len(tags) == 0 { + return + } + headers := url.Values{} + for k, v := range tags { + headers.Add(k, v) + } + i.Tagging = aws.String(headers.Encode()) +} + func (c *RemoteClient) getMD5(ctx context.Context) ([]byte, error) { if c.ddbTable == "" { return nil, nil diff --git a/internal/backend/remote-state/s3/client_test.go b/internal/backend/remote-state/s3/client_test.go index 3ea910382f20..e0caf5d68995 100644 --- a/internal/backend/remote-state/s3/client_test.go +++ b/internal/backend/remote-state/s3/client_test.go @@ -57,6 +57,35 @@ func TestRemoteClientBasic(t *testing.T) { remote.TestClient(t, state.(*remote.State).Client) } +func TestRemoteClientTags(t *testing.T) { + testACC(t) + + ctx := context.TODO() + + bucketName := fmt.Sprintf("terraform-remote-s3-test-%x", time.Now().Unix()) + keyName := "testState" + + b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ + "bucket": bucketName, + "key": keyName, + "encrypt": true, + "use_lockfile": true, + "state_tags": map[string]string{"type": "state", "tag2": "value2"}, + "lock_tags": map[string]string{"type": "lock", "tag2": "value2", "tag3": "value3"}, + })).(*Backend) + + createS3Bucket(ctx, t, b.s3Client, bucketName, b.awsConfig.Region) + defer deleteS3Bucket(ctx, t, b.s3Client, bucketName, b.awsConfig.Region) + + state, sDiags := b.StateMgr(backend.DefaultStateName) + if sDiags.HasErrors() { + t.Fatal(sDiags) + } + + remote.TestClient(t, state.(*remote.State).Client) +} + + func TestRemoteClientLocks(t *testing.T) { testACC(t)