Skip to content
Merged
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
105 changes: 103 additions & 2 deletions pkg/awsutil/secrets.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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] + "..."
}
36 changes: 36 additions & 0 deletions pkg/awsutil/secrets_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package awsutil

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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))
}
Loading