Updates for getting private key from AWS secrets manager (#32789)

for #31321 

# Details

Small updates from [community
PR](https://github.com/fleetdm/fleet/pull/31134):

* Updated config vars to match
[docs](https://github.com/fleetdm/fleet/blob/docs-v4.75.0/docs/Configuration/fleet-server-configuration.md#server_private_key_region)
* Added support for specifying region in config (already documented)
* Removed parsing of ARN for region
* Made retry backoff intervals a bit longer

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
(already added in the community PR
[here](https://github.com/fleetdm/fleet/blob/sgress454/updates-for-private-key-in-aws-sm/changes/private-key-secrets-manager#L0-L1)

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Added support for specifying the AWS region for server private key
retrieval from AWS Secrets Manager via server.private_key_region.

- Chores
  - Renamed configuration keys:
    - server.private_key_secret_arn → server.private_key_arn
- server.private_key_secret_sts_assume_role_arn →
server.private_key_sts_assume_role_arn
- server.private_key_secret_sts_external_id →
server.private_key_sts_external_id
  - Update your configuration to use the new keys.
- Adjusted retry backoff for Secrets Manager retrieval to improve
resilience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress
2025-09-19 10:57:02 -05:00
committed by GitHub
parent 7d89631aca
commit e37aa6cf29
8 changed files with 155 additions and 126 deletions
+3
View File
@@ -91,6 +91,7 @@ jobs:
if [[ "${{ matrix.suite }}" == "main" ]]; then
echo "CI_TEST_PKG=main" >> $GITHUB_ENV
echo "NEED_DOCKER=1" >> $GITHUB_ENV
echo "DOCKER_COMMAND=${{ env.DOCKER_COMMAND }} localstack" >> $GITHUB_ENV
elif [[ "${{ matrix.suite }}" == "fast" ]]; then
# DO NOT add any dependencies in this test suite.
echo "CI_TEST_PKG=${{ matrix.suite }}" >> $GITHUB_ENV
@@ -258,6 +259,8 @@ jobs:
MINIO_STORAGE_TEST=1 \
SAML_IDP_TEST=1 \
MAIL_TEST=1 \
AWS_ENDPOINT_URL="http://localhost:4566" \
AWS_REGION=us-east-1 \
NETWORK_TEST_GITHUB_TOKEN=${{ secrets.FLEET_RELEASE_GITHUB_PAT }} \
CI_TEST_PKG="${{ env.CI_TEST_PKG }}" \
make test-go 2>&1 | tee /tmp/gotest.log
+1
View File
@@ -203,6 +203,7 @@ the way that the Fleet server works.
privateKey, err := configpkg.RetrieveSecretsManagerSecret(
context.Background(),
config.Server.PrivateKeySecretArn,
config.Server.PrivateKeySecretRegion,
config.Server.PrivateKeySecretSTSAssumeRoleArn,
config.Server.PrivateKeySecretSTSExternalID,
)
+36 -21
View File
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/fleetdm/fleet/v4/pkg/testutils"
"github.com/hashicorp/go-multierror"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -161,8 +162,11 @@ func TestExpandEnv(t *testing.T) {
{map[string]string{"foo": "", "bar": "", "zoo": ""}, `$foo${bar}$zoo`, ``, nil},
{map[string]string{}, `$foo`, ``, checkMultiErrors(t, "environment variable \"foo\" not set")},
{map[string]string{"foo": "1"}, `$foo$bar`, ``, checkMultiErrors(t, "environment variable \"bar\" not set")},
{map[string]string{"bar": "1"}, `$foo $bar $zoo`, ``,
checkMultiErrors(t, "environment variable \"foo\" not set", "environment variable \"zoo\" not set")},
{
map[string]string{"bar": "1"},
`$foo $bar $zoo`, ``,
checkMultiErrors(t, "environment variable \"foo\" not set", "environment variable \"zoo\" not set"),
},
{map[string]string{"foo": "4", "bar": "2"}, `$foo$bar`, `42`, nil},
{map[string]string{"foo": "42", "bar": ""}, `$foo$bar`, `42`, nil},
{map[string]string{}, `$$`, ``, checkMultiErrors(t, "environment variable \"$\" not set")},
@@ -180,9 +184,14 @@ func TestExpandEnv(t *testing.T) {
{map[string]string{"foo": "", "$": "2"}, `${$}${foo}var`, `2var`, nil},
{map[string]string{}, `${foo}var`, ``, checkMultiErrors(t, "environment variable \"foo\" not set")},
{map[string]string{}, `foo PREVENT_ESCAPING_bar $ FLEET_VAR_`, `foo PREVENT_ESCAPING_bar $ FLEET_VAR_`, nil}, // nothing to replace
{map[string]string{"foo": "BAR"}, `\$FLEET_VAR_$foo \${FLEET_VAR_$foo} \${FLEET_VAR_${foo}2}`,
`$FLEET_VAR_BAR ${FLEET_VAR_BAR} ${FLEET_VAR_BAR2}`, nil}, // nested variables
{
map[string]string{"foo": "BAR"},
`\$FLEET_VAR_$foo \${FLEET_VAR_$foo} \${FLEET_VAR_${foo}2}`,
`$FLEET_VAR_BAR ${FLEET_VAR_BAR} ${FLEET_VAR_BAR2}`, nil,
}, // nested variables
} {
// save the current env before clearing it.
testutils.SaveEnv(t)
os.Clearenv()
for k, v := range tc.environment {
_ = os.Setenv(k, v)
@@ -206,9 +215,15 @@ func TestLookupEnvSecrets(t *testing.T) {
}{
{map[string]string{"foo": "1"}, `$foo`, map[string]string{}, nil},
{map[string]string{"FLEET_SECRET_foo": "1"}, `$FLEET_SECRET_foo`, map[string]string{"FLEET_SECRET_foo": "1"}, nil},
{map[string]string{"foo": "1"}, `$FLEET_SECRET_foo`, map[string]string{},
checkMultiErrors(t, "environment variable \"FLEET_SECRET_foo\" not set")},
{
map[string]string{"foo": "1"},
`$FLEET_SECRET_foo`,
map[string]string{},
checkMultiErrors(t, "environment variable \"FLEET_SECRET_foo\" not set"),
},
} {
// save the current env before clearing it.
testutils.SaveEnv(t)
os.Clearenv()
for k, v := range tc.environment {
_ = os.Setenv(k, v)
@@ -262,26 +277,26 @@ func TestGetExclusionZones(t *testing.T) {
{
[]string{"testdata", "policies", "policies.yml"},
map[[2]int]string{
[2]int{46, 106}: " description: This policy should always fail.\n resolution:",
[2]int{93, 155}: " resolution: There is no resolution for this policy.\n query:",
[2]int{268, 328}: " description: This policy should always pass.\n resolution:",
[2]int{315, 678}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
{46, 106}: " description: This policy should always fail.\n resolution:",
{93, 155}: " resolution: There is no resolution for this policy.\n query:",
{268, 328}: " description: This policy should always pass.\n resolution:",
{315, 678}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
},
},
{
[]string{"testdata", "global_config_no_paths.yml"},
map[[2]int]string{
[2]int{866, 949}: " description: Collect osquery performance stats directly from osquery\n query:", //
[2]int{1754, 1818}: " description: This policy should always fail.\n resolution:", //
[2]int{1803, 1869}: " resolution: There is no resolution for this policy.\n query:", //
[2]int{1986, 2050}: " description: This policy should always pass.\n resolution:", //
[2]int{2035, 2101}: " resolution: There is no resolution for this policy.\n query:", //
[2]int{2394, 2458}: " description: This policy should always fail.\n resolution:", //
[2]int{2443, 2509}: " resolution: There is no resolution for this policy.\n query:", //
[2]int{2613, 2677}: " description: This policy should always fail.\n resolution:", //
[2]int{2662, 3035}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
[2]int{6102, 6149}: " description: A cool global label\n query:", //
[2]int{6246, 6292}: " description: A fly global label\n hosts:", //
{866, 949}: " description: Collect osquery performance stats directly from osquery\n query:", //
{1754, 1818}: " description: This policy should always fail.\n resolution:", //
{1803, 1869}: " resolution: There is no resolution for this policy.\n query:", //
{1986, 2050}: " description: This policy should always pass.\n resolution:", //
{2035, 2101}: " resolution: There is no resolution for this policy.\n query:", //
{2394, 2458}: " description: This policy should always fail.\n resolution:", //
{2443, 2509}: " resolution: There is no resolution for this policy.\n query:", //
{2613, 2677}: " description: This policy should always fail.\n resolution:", //
{2662, 3035}: " resolution: |\n Automated method:\n Ask your system administrator to deploy the following script which will ensure proper Security Auditing Retention:\n cp /etc/security/audit_control ./tmp.txt; origExpire=$(cat ./tmp.txt | grep expire-after); sed \"s/${origExpire}/expire-after:60d OR 5G/\" ./tmp.txt > /etc/security/audit_control; rm ./tmp.txt;\n query:",
{6102, 6149}: " description: A cool global label\n query:", //
{6246, 6292}: " description: A fly global label\n hosts:", //
},
},
}
+30
View File
@@ -0,0 +1,30 @@
package testutils
import (
"os"
"strings"
"testing"
)
// SaveEnv snapshots the current environment and restores it when the test
// ends.
//
// Do _not_ use this in parallel tests, as it clears the entire environment.
func SaveEnv(t *testing.T) {
saved := os.Environ()
t.Cleanup(func() {
os.Clearenv()
for _, kv := range saved {
parts := strings.SplitN(kv, "=", 2)
key := parts[0]
val := ""
if len(parts) == 2 {
val = parts[1]
}
err := os.Setenv(key, val)
if err != nil {
t.Logf("error restoring env var %s: %v", key, err)
}
}
})
}
+12 -9
View File
@@ -107,9 +107,10 @@ type ServerConfig struct {
FrequentCleanupsEnabled bool `yaml:"frequent_cleanups_enabled"`
ForceH2C bool `yaml:"force_h2c"`
PrivateKey string `yaml:"private_key"`
PrivateKeySecretArn string `yaml:"private_key_secret_arn"`
PrivateKeySecretSTSAssumeRoleArn string `yaml:"private_key_secret_sts_assume_role_arn"`
PrivateKeySecretSTSExternalID string `yaml:"private_key_secret_sts_external_id"`
PrivateKeySecretArn string `yaml:"private_key_arn"`
PrivateKeySecretRegion string `yaml:"private_key_region"`
PrivateKeySecretSTSAssumeRoleArn string `yaml:"private_key_sts_assume_role_arn"`
PrivateKeySecretSTSExternalID string `yaml:"private_key_sts_external_id"`
VPPVerifyTimeout time.Duration `yaml:"vpp_verify_timeout"`
VPPVerifyRequestDelay time.Duration `yaml:"vpp_verify_request_delay"`
}
@@ -1124,9 +1125,10 @@ func (man Manager) addConfigs() {
man.addConfigBool("server.frequent_cleanups_enabled", false, "Enable frequent cleanups of expired data (15 minute interval)")
man.addConfigBool("server.force_h2c", false, "Force the fleet server to use HTTP2 cleartext aka h2c (ignored if using TLS)")
man.addConfigString("server.private_key", "", "Used for encrypting sensitive data, such as MDM certificates.")
man.addConfigString("server.private_key_secret_arn", "", "ARN of AWS Secrets Manager secret containing server private key")
man.addConfigString("server.private_key_secret_sts_assume_role_arn", "", "ARN of role to assume for accessing private key secret")
man.addConfigString("server.private_key_secret_sts_external_id", "", "External ID for STS role assumption when accessing private key secret")
man.addConfigString("server.private_key_region", "", "AWS region of the Secrets Manager secret containing server private key")
man.addConfigString("server.private_key_arn", "", "ARN of AWS Secrets Manager secret containing server private key")
man.addConfigString("server.private_key_sts_assume_role_arn", "", "ARN of role to assume for accessing private key secret")
man.addConfigString("server.private_key_sts_external_id", "", "External ID for STS role assumption when accessing private key secret")
man.addConfigDuration("server.vpp_verify_timeout", 10*time.Minute, "Maximum amout of time to wait for VPP app install verification")
man.addConfigDuration("server.vpp_verify_request_delay", 5*time.Second, "Delay in between requests to verify VPP app installs")
@@ -1566,9 +1568,10 @@ func (man Manager) LoadConfig() FleetConfig {
FrequentCleanupsEnabled: man.getConfigBool("server.frequent_cleanups_enabled"),
ForceH2C: man.getConfigBool("server.force_h2c"),
PrivateKey: man.getConfigString("server.private_key"),
PrivateKeySecretArn: man.getConfigString("server.private_key_secret_arn"),
PrivateKeySecretSTSAssumeRoleArn: man.getConfigString("server.private_key_secret_sts_assume_role_arn"),
PrivateKeySecretSTSExternalID: man.getConfigString("server.private_key_secret_sts_external_id"),
PrivateKeySecretArn: man.getConfigString("server.private_key_arn"),
PrivateKeySecretRegion: man.getConfigString("server.private_key_region"),
PrivateKeySecretSTSAssumeRoleArn: man.getConfigString("server.private_key_sts_assume_role_arn"),
PrivateKeySecretSTSExternalID: man.getConfigString("server.private_key_sts_external_id"),
VPPVerifyTimeout: man.getConfigDuration("server.vpp_verify_timeout"),
VPPVerifyRequestDelay: man.getConfigDuration("server.vpp_verify_request_delay"),
},
+7 -1
View File
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/testutils"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -30,6 +31,9 @@ func TestConfigRoundtrip(t *testing.T) {
// viper tries to load config from the environment too, clear it in case
// any config values are set in the environment.
// save the current env before clearing it.
testutils.SaveEnv(t)
os.Clearenv()
cmd := &cobra.Command{}
@@ -302,7 +306,9 @@ osquery:
// test-case values, but that didn't seem to work, not sure how it can
// be done in our particular setup.
// set the environment variables
// save the current env before clearing it.
testutils.SaveEnv(t)
os.Clearenv()
for _, env := range c.envVars {
kv := strings.SplitN(env, "=", 2)
+5 -27
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"math/rand"
"strings"
"time"
aws_config "github.com/aws/aws-sdk-go-v2/config"
@@ -20,22 +19,6 @@ type SecretsManagerClient interface {
optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error)
}
// parseRegionFromSecretARN extracts the AWS region from a Secrets Manager ARN
func parseRegionFromSecretARN(arn string) (string, error) {
// ARN format: arn:aws:secretsmanager:region:account:secret:name
parts := strings.Split(arn, ":")
if len(parts) < 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "secretsmanager" {
return "", fmt.Errorf("invalid Secrets Manager ARN format: %s", arn)
}
region := parts[3]
if region == "" {
return "", fmt.Errorf("region not found in ARN: %s", arn)
}
return region, nil
}
// retrieveSecretWithRetry retrieves the secret from AWS with retry logic
func retrieveSecretWithRetry(ctx context.Context, client SecretsManagerClient, secretArn string) (string, error) {
const maxRetries = 3
@@ -43,8 +26,8 @@ func retrieveSecretWithRetry(ctx context.Context, client SecretsManagerClient, s
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff with jitter: base 100ms with ±50% randomization
baseBackoff := time.Duration(100*(1<<uint(attempt-1))) * time.Millisecond // #nosec G115 - attempt is bounded by maxRetries
// Exponential backoff with jitter: base 500ms with ±50% randomization
baseBackoff := time.Duration(500*(1<<uint(attempt-1))) * time.Millisecond // #nosec G115 - attempt is bounded by maxRetries
jitter := time.Duration(rand.Float64()*float64(baseBackoff)) - baseBackoff/2 // #nosec G404 - not security sensitive
backoff := baseBackoff + jitter
select {
@@ -98,18 +81,13 @@ func retrieveSecretWithRetry(ctx context.Context, client SecretsManagerClient, s
// RetrieveSecretsManagerSecret retrieves a secret from AWS Secrets Manager
// with support for STS assume role authentication
func RetrieveSecretsManagerSecret(ctx context.Context, secretArn, assumeRoleArn, externalID string) (string, error) {
return RetrieveSecretsManagerSecretWithOptions(ctx, secretArn, assumeRoleArn, externalID)
func RetrieveSecretsManagerSecret(ctx context.Context, secretArn, region, assumeRoleArn, externalID string) (string, error) {
return RetrieveSecretsManagerSecretWithOptions(ctx, secretArn, region, assumeRoleArn, externalID)
}
// RetrieveSecretsManagerSecretWithOptions retrieves a secret from AWS Secrets Manager
// with custom AWS config options (useful for testing with LocalStack)
func RetrieveSecretsManagerSecretWithOptions(ctx context.Context, secretArn, assumeRoleArn, externalID string, opts ...func(*aws_config.LoadOptions) error) (string, error) {
region, err := parseRegionFromSecretARN(secretArn)
if err != nil {
return "", fmt.Errorf("invalid secret ARN: %w", err)
}
func RetrieveSecretsManagerSecretWithOptions(ctx context.Context, secretArn, region, assumeRoleArn, externalID string, opts ...func(*aws_config.LoadOptions) error) (string, error) {
configOpts := []func(*aws_config.LoadOptions) error{aws_config.WithRegion(region)}
configOpts = append(configOpts, opts...)
cfg, err := aws_config.LoadDefaultConfig(ctx, configOpts...)
+61 -68
View File
@@ -3,7 +3,6 @@ package config
import (
"context"
"errors"
"fmt"
"os"
"testing"
@@ -31,59 +30,6 @@ func (m *mockSecretsManagerClient) GetSecretValue(ctx context.Context, params *s
return args.Get(0).(*secretsmanager.GetSecretValueOutput), args.Error(1)
}
func TestParseRegionFromARN(t *testing.T) {
testCases := []struct {
name string
arn string
expectedReg string
expectError bool
}{
{
name: "valid ARN",
arn: "arn:aws:secretsmanager:us-west-2:123456789012:secret:fleet-private-key-abc123",
expectedReg: "us-west-2",
expectError: false,
},
{
name: "valid ARN different region",
arn: "arn:aws:secretsmanager:eu-central-1:123456789012:secret:my-secret-def456",
expectedReg: "eu-central-1",
expectError: false,
},
{
name: "invalid ARN format",
arn: "invalid-arn-format",
expectedReg: "",
expectError: true,
},
{
name: "wrong service",
arn: "arn:aws:s3:us-west-2:123456789012:bucket/my-bucket",
expectedReg: "",
expectError: true,
},
{
name: "empty region",
arn: "arn:aws:secretsmanager::123456789012:secret:my-secret",
expectedReg: "",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
region, err := parseRegionFromSecretARN(tc.arn)
if tc.expectError {
assert.Error(t, err)
assert.Empty(t, region)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedReg, region)
}
})
}
}
func TestRetrieveSecretWithRetry_Success(t *testing.T) {
mockClient := &mockSecretsManagerClient{}
expectedKey := "test-32-byte-key-for-aes-encryption"
@@ -213,31 +159,25 @@ func TestRetrieveSecretWithRetry_ContextCancellation(t *testing.T) {
mockClient.AssertExpectations(t)
}
func TestRetrieveSecretsManagerSecret_LocalStack(t *testing.T) {
if os.Getenv("LOCALSTACK_URL") == "" {
t.Skip("LOCALSTACK_URL not set, skipping LocalStack integration test")
func TestRetrieveSecretsManagerSecret_LocalStackDefaultRegion(t *testing.T) {
awsEndpointURL := os.Getenv("AWS_ENDPOINT_URL")
if awsEndpointURL == "" {
t.Skip("AWS_ENDPOINT_URL not set, skipping LocalStack integration test")
}
ctx := context.Background()
localStackURL := os.Getenv("LOCALSTACK_URL")
if localStackURL == "" {
localStackURL = "http://localhost:4566"
}
localStackOpts := []func(*aws_config.LoadOptions) error{
aws_config.WithBaseEndpoint(localStackURL),
aws_config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
}
// Configure LocalStack client
cfg, err := aws_config.LoadDefaultConfig(ctx, append(localStackOpts, aws_config.WithRegion("us-east-1"))...)
cfg, err := aws_config.LoadDefaultConfig(ctx, localStackOpts...)
require.NoError(t, err)
client := secretsmanager.NewFromConfig(cfg)
secretName := "fleet-test-private-key-localstack"
privateKey := "test-key-exactly-32-bytes-long!"
secretArn := fmt.Sprintf("arn:aws:secretsmanager:us-east-1:000000000000:secret:%s", secretName)
// Clean up any existing secret
_, _ = client.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
@@ -245,12 +185,13 @@ func TestRetrieveSecretsManagerSecret_LocalStack(t *testing.T) {
ForceDeleteWithoutRecovery: aws.Bool(true),
})
_, err = client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
output, err := client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: &secretName,
SecretString: &privateKey,
Description: aws.String("password"),
})
require.NoError(t, err, "Failed to create secret in LocalStack")
secretArn := *output.ARN
// Clean up after test
defer func() {
@@ -259,13 +200,65 @@ func TestRetrieveSecretsManagerSecret_LocalStack(t *testing.T) {
ForceDeleteWithoutRecovery: aws.Bool(true),
})
}()
retrievedKey, err := RetrieveSecretsManagerSecretWithOptions(ctx, secretArn, "", "", localStackOpts...)
retrievedKey, err := RetrieveSecretsManagerSecretWithOptions(ctx, secretArn, "", "", "", localStackOpts...)
require.NoError(t, err)
assert.Equal(t, privateKey, retrievedKey)
// Test with invalid ARN
invalidArn := "arn:aws:secretsmanager:us-east-1:000000000000:secret:nonexistent-secret"
_, err = RetrieveSecretsManagerSecretWithOptions(ctx, invalidArn, "", "", localStackOpts...)
_, err = RetrieveSecretsManagerSecretWithOptions(ctx, invalidArn, "", "", "", localStackOpts...)
require.Error(t, err)
assert.Contains(t, err.Error(), "secret not found")
}
func TestRetrieveSecretsManagerSecret_LocalStackDifferentRegion(t *testing.T) {
awsEndpointURL := os.Getenv("AWS_ENDPOINT_URL")
if awsEndpointURL == "" {
t.Skip("AWS_ENDPOINT_URL not set, skipping LocalStack integration test")
}
ctx := context.Background()
localStackOpts := []func(*aws_config.LoadOptions) error{
aws_config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
}
// Configure LocalStack client
cfg, err := aws_config.LoadDefaultConfig(ctx, append(localStackOpts, aws_config.WithRegion("us-east-2"))...)
require.NoError(t, err)
client := secretsmanager.NewFromConfig(cfg)
secretName := "fleet-test-private-key-localstack"
privateKey := "test-key-exactly-32-bytes-long!"
// Clean up any existing secret
_, _ = client.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
SecretId: &secretName,
ForceDeleteWithoutRecovery: aws.Bool(true),
})
output, err := client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: &secretName,
SecretString: &privateKey,
Description: aws.String("password"),
})
require.NoError(t, err, "Failed to create secret in LocalStack")
secretArn := *output.ARN
// Clean up after test
defer func() {
_, _ = client.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
SecretId: &secretName,
ForceDeleteWithoutRecovery: aws.Bool(true),
})
}()
retrievedKey, err := RetrieveSecretsManagerSecretWithOptions(ctx, secretArn, "us-east-2", "", "", localStackOpts...)
require.NoError(t, err)
assert.Equal(t, privateKey, retrievedKey)
// Test with invalid ARN
invalidArn := "arn:aws:secretsmanager:us-east-1:000000000000:secret:nonexistent-secret"
_, err = RetrieveSecretsManagerSecretWithOptions(ctx, invalidArn, "us-east-2", "", "", localStackOpts...)
require.Error(t, err)
assert.Contains(t, err.Error(), "secret not found")
}