diff --git a/pkg/awsutil/secrets.go b/pkg/awsutil/secrets.go index 1b352d6..5ffae63 100644 --- a/pkg/awsutil/secrets.go +++ b/pkg/awsutil/secrets.go @@ -1,19 +1,28 @@ package awsutil import ( + "context" + "encoding/json" + "fmt" "os" "strings" "sync" - "github.com/pkg/errors" - "github.com/aws/aws-secretsmanager-caching-go/secretcache" + "github.com/pkg/errors" ) // secretsManagerArnPrefix marks an env value that is a reference to a secret // rather than the secret itself. const secretsManagerArnPrefix = "arn:aws:secretsmanager" +// SecretsBundleEnv is the env name every service reads to find the bundle +// Secrets Manager ARN. SC's Pulumi renderer writes exactly this env name +// when `cloudExtras.secretsBundle` is set — see +// `forge/docs/design/2026-10-17/sc-secrets-bundle-mode/README.md` §3.3. +// Services that want a different env name can call LoadSecretBundleFromEnv. +const SecretsBundleEnv = "FORGE_SECRETS_BUNDLE_ARN" + var ( secretCacheOnce sync.Once secretCache *secretcache.Cache @@ -58,3 +67,95 @@ func GetEnvOrSecret(envName string) (string, error) { } return secretValue, nil } + +// LoadSecretBundle resolves the Secrets Manager ARN carried by +// FORGE_SECRETS_BUNDLE_ARN (the SecretsBundleEnv constant), parses it as a +// JSON object of {envName:string → value:string}, and os.Setenv's each key. +// +// Services that opt into bundle mode (SC `cloudExtras.secretsBundle`, +// design 2026-10-17) call this ONCE from main() BEFORE any code reads a +// secret env. After it returns, every GetEnvOrSecret("TAVILY_API_KEY") +// (and every plain os.Getenv) returns the loaded value — the rest of the +// fleet continues to use the same env-read contract it already uses. +// +// Design decisions worth knowing: +// +// - A missing SecretsBundleEnv is a NO-OP (nil error). Legacy per-key +// ARN envs continue to work via GetEnvOrSecret unchanged. This lets +// the SDK pin update roll out before any service opts in. +// +// - A set-but-not-ARN SecretsBundleEnv value is a hard error — the +// operator typo'd something; failing loudly beats loading garbage. +// +// - The underlying fetch rides the SAME secretcache.Cache that +// GetEnvOrSecret uses, so no second AWS session is built. On Yandex +// Cloud (no AWS creds) the cache stays unbuilt because the bundle +// env isn't set either. +// +// - os.Setenv NEVER overwrites a value already set in the environment +// (bundle values yield to anything the operator pinned via `env:` +// in client.yaml). This matches the fleet-wide expectation that +// literal env wins. +// +// - JSON values that are not strings are rejected (an operator who +// seeds a nested object by accident gets a loud error, not silent +// flattening into Go's default string format). +func LoadSecretBundle(ctx context.Context) error { + return LoadSecretBundleFromEnv(ctx, SecretsBundleEnv) +} + +// LoadSecretBundleFromEnv is the explicit-env-name variant for services +// that need a different bundle env name (e.g. staged migrations). Shares +// LoadSecretBundle's semantics otherwise. +func LoadSecretBundleFromEnv(ctx context.Context, envName string) error { + _ = ctx // kept on the signature so a future retry/timeout wrap doesn't break callers. + + arn := os.Getenv(envName) + if arn == "" { + return nil + } + if !strings.HasPrefix(arn, secretsManagerArnPrefix) { + return fmt.Errorf("%s is set but is not a Secrets Manager ARN (got prefix %q)", envName, firstN(arn, 32)) + } + + cache, err := getSecretCache() + if err != nil { + return errors.Wrapf(err, "load secret bundle: get cache") + } + payload, err := cache.GetSecretString(arn) + if err != nil { + return errors.Wrapf(err, "load secret bundle: fetch %s", envName) + } + + // Parse as JSON object of string values. Non-string values are a + // loud error — see the doc comment. + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(payload), &raw); err != nil { + return errors.Wrapf(err, "load secret bundle: decode JSON") + } + + for key, msg := range raw { + var value string + if err := json.Unmarshal(msg, &value); err != nil { + return fmt.Errorf("load secret bundle: value for %q is not a string", key) + } + if _, present := os.LookupEnv(key); present { + // Operator-set env wins. Skip silently — the operator pinned + // a literal in client.yaml deliberately. + continue + } + if err := os.Setenv(key, value); err != nil { + return errors.Wrapf(err, "load secret bundle: setenv %q", key) + } + } + return nil +} + +// firstN returns the first n runes of s (safe on short strings), used for +// bounded error messages that must not leak a long ARN or payload. +func firstN(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/pkg/awsutil/secrets_test.go b/pkg/awsutil/secrets_test.go index fb73a77..71d78a7 100644 --- a/pkg/awsutil/secrets_test.go +++ b/pkg/awsutil/secrets_test.go @@ -1,6 +1,7 @@ package awsutil import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -26,3 +27,38 @@ func TestGetEnvOrSecretReturnsEmptyForUnsetVariable(t *testing.T) { require.NoError(t, err) assert.Empty(t, value) } + +// TestLoadSecretBundleUnsetIsNoop pins the "SDK pin can roll out before any +// service opts in" contract: an unset bundle env must not error and must not +// touch the Secrets Manager cache. +func TestLoadSecretBundleUnsetIsNoop(t *testing.T) { + // Explicitly unset (t.Setenv then unset is safe for test isolation). + t.Setenv(SecretsBundleEnv, "") + require.NoError(t, LoadSecretBundle(context.Background())) +} + +// TestLoadSecretBundleRejectsNonArn — operator typo'd a plain string into +// the bundle env; fail loudly so the misconfig surfaces instead of being +// silently ignored. +func TestLoadSecretBundleRejectsNonArn(t *testing.T) { + t.Setenv(SecretsBundleEnv, "oops-not-an-arn") + err := LoadSecretBundle(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a Secrets Manager ARN") +} + +// TestLoadSecretBundleFromEnvRespectsExplicitEnvName lets a service pick +// a bundle env name that isn't FORGE_SECRETS_BUNDLE_ARN (useful for staged +// migrations). Default remains the constant for the common path. +func TestLoadSecretBundleFromEnvRespectsExplicitEnvName(t *testing.T) { + t.Setenv("MY_OTHER_BUNDLE", "") + require.NoError(t, LoadSecretBundleFromEnv(context.Background(), "MY_OTHER_BUNDLE")) +} + +// TestFirstN pins the error-message truncation helper (used in the +// non-ARN rejection path to avoid leaking a long value into the log). +func TestFirstN(t *testing.T) { + assert.Equal(t, "abc", firstN("abc", 10)) + assert.Equal(t, "abcde", firstN("abcde", 5)) + assert.Equal(t, "abc...", firstN("abcdefghij", 3)) +}