Description
Adds support for loading all of Thermos's environment variables from a single
AWS Secrets Manager secret at startup, gated by a new optional env var
AWS_SECRETS_MANAGER_SECRET_ID.
Why
Today, every Thermos required env var (THERMOS_DATABASE_URL,
LAMBDA_FUNCTION_NAME, COMPOSIO_ENV, …) has to be plumbed individually
through container env / pod spec. For deployments that already manage secrets
in AWS Secrets Manager — particularly self-hosted / on-prem installs — that
means duplicating credentials across two systems.
This PR lets operators put one Secrets Manager secret of the form
{
"THERMOS_DATABASE_URL": "postgres://…",
"LAMBDA_FUNCTION_NAME": "mercury-prod",
"COMPOSIO_ENV": "production",
"TEMPORAL_API_KEY": "…",
…
}
and point Thermos at it with AWS_SECRETS_MANAGER_SECRET_ID=thermos/prod.
What changed
lib/awsutils/secrets.go (new)
BootstrapSecretsFromEnv(ctx, logger) — reads AWS_SECRETS_MANAGER_SECRET_ID
from the env. No-op when unset (fully backward-compatible).
LoadSecretsIntoEnv(ctx, secretID, region, awsConfig, logger) — fetches
the secret, parses it as {string: string} JSON, and writes each pair via
os.Setenv. The secret is authoritative — values overwrite any pre-existing
env value.
- Supports both
SecretString and SecretBinary storage modes.
- Hard-fails on any error (fetch / empty / malformed JSON / non-string value /
empty key) so misconfiguration surfaces at startup rather than as a confusing
downstream error.
main.go — moves ProvideLogger ahead of the env sanity check, calls
awsutils.BootstrapSecretsFromEnv between godotenv.Load() and
env.SanityCheck(). This ordering lets required env vars live inside the
secret without breaking the sanity check.
lib/env/env.go — registers AWS_SECRETS_MANAGER_SECRET_ID_VAR and
AWS_SECRETS_MANAGER_REGION_VAR (both Optional) in the EnvRegistry.
go.mod / go.sum — adds github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7
(pulls in minor patch bumps of transitive AWS SDK modules).
apps/thermos/CLAUDE.md — documents the two new env vars and the IRSA
fallback behavior.
Design notes
- IAM role support is preserved. The fetch reuses
awsutils.AWSUtilsConfig.Load(region), which only injects static credentials
when both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are non-empty
(see lib/awsutils/config.go:21-36). When unset, the SDK default credential
chain takes over — IRSA via AWS_WEB_IDENTITY_TOKEN_FILE, ECS task role, EC2
instance metadata, etc. No static credentials are introduced.
- Region resolution:
AWS_SECRETS_MANAGER_REGION if set, otherwise falls
back to AWS_LAMBDA_REGION (default us-east-2) so a typical deployment
needs zero extra config.
- Precedence: Secrets Manager values overwrite existing env vars — the
secret is the source of truth. (
AWS_ACCESS_KEY_ID /
AWS_SECRET_ACCESS_KEY must NOT be placed inside the secret, since they
are needed to fetch it; this is documented in the CLAUDE.md note.)
- Bootstrap ordering: Loaded before
env.SanityCheck so the secret can
satisfy required env vars (COMPOSIO_ENV, LAMBDA_FUNCTION_NAME).
- Testability: the secrets-manager client is constructed through an
overridable factory (
newSecretsManagerClient), so unit tests can inject a
fake without hitting AWS.
Backward compatibility
Fully backward-compatible. When AWS_SECRETS_MANAGER_SECRET_ID is unset (the
default), BootstrapSecretsFromEnv logs at debug level and returns nil — no
AWS call is made and behavior is identical to today.
How did I test this PR
-
Unit tests for the new package — 13 cases covering:
- empty
secretID is a no-op (no API call)
- happy path: JSON
SecretString → os.Setenv (and verifies overwrite of
a pre-existing env value)
SecretBinary storage path
- fetch error propagates with secret ID + cause
- empty payload (no
SecretString and no SecretBinary)
- invalid JSON → clear error
- non-string value (
{"PORT": 8080}) → clear error
- top-level array rejected
- empty key in JSON → clear error
- empty JSON object is a valid no-op
- bootstrap is a no-op when env var unset
- region fallback to
AWS_LAMBDA_REGION when AWS_SECRETS_MANAGER_REGION is empty
- explicit
AWS_SECRETS_MANAGER_REGION overrides
cd apps/thermos && go test ./lib/awsutils/... -count=1 -v
→ all 24 tests pass (13 new + 11 pre-existing).
-
Full thermos test suite — cd apps/thermos && go test ./... → all packages green.
-
Static checks — go build ./..., go vet ./..., gofmt -l on touched
files, and pnpm lint:ent-client-usage all clean. (pnpm lint not runnable
in this sandbox: the installed golangci-lint is built against Go 1.23 while
the repo targets 1.26.2 — CI will run the real lint.)
-
Backward-compat verification — confirmed the loader returns nil and
emits a debug log when AWS_SECRETS_MANAGER_SECRET_ID is empty
(TestLoadSecretsIntoEnv_EmptySecretIDIsNoOp /
TestBootstrapSecretsFromEnv_UnsetSecretIDIsNoOp), so existing deployments
are unaffected.
🤖 Generated with Claude Code