Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.15/NEW FEATURES-20251224-132008.yaml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions internal/backend/remote-state/s3/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/backend/remote-state/s3/backend_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions internal/backend/remote-state/s3/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -225,6 +309,7 @@ func TestBackendConfig_withLockfile(t *testing.T) {
}
}


func TestBackendConfig_multiLock(t *testing.T) {
testACC(t)

Expand Down
16 changes: 16 additions & 0 deletions internal/backend/remote-state/s3/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"io"
"log"
"net/url"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions internal/backend/remote-state/s3/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading