-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(secrets): ingest env secrets at container runtime instead of fanning into ECS taskdef #5189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TheodoreSpeaks
wants to merge
2
commits into
staging
Choose a base branch
from
feat/runtime-secrets-ingestion
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /** | ||
| * Container entrypoint. Hydrates `process.env` from the runtime secret before | ||
| * loading the Socket.IO server, whose modules (`@/env`, DB preflight) read env | ||
| * at import time. See `@sim/runtime-secrets`. | ||
| */ | ||
| import { loadRuntimeSecrets } from '@sim/runtime-secrets' | ||
|
|
||
| await loadRuntimeSecrets() | ||
| await import('@/index') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /** | ||
| * Container entrypoint. Hydrates `process.env` from the runtime secret before | ||
| * loading the Next.js standalone server, so application modules that read env at | ||
| * import time see the full configuration. See `@sim/runtime-secrets`. | ||
| */ | ||
| import { loadRuntimeSecrets } from '@sim/runtime-secrets' | ||
|
|
||
| await loadRuntimeSecrets() | ||
| // `server.js` is the Next standalone build artifact, a sibling of this file in | ||
| // the image; it does not exist at type-check time, so the specifier is held in a | ||
| // variable to keep it out of static module resolution. | ||
| const standaloneServer = './server.js' | ||
| await import(standaloneServer) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,4 +49,4 @@ USER nextjs | |
|
|
||
| EXPOSE 3002 | ||
|
|
||
| CMD ["bun", "apps/realtime/src/index.ts"] | ||
| CMD ["bun", "apps/realtime/src/bootstrap.ts"] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "name": "@sim/runtime-secrets", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "sideEffects": false, | ||
| "type": "module", | ||
| "license": "Apache-2.0", | ||
| "engines": { | ||
| "bun": ">=1.2.13", | ||
| "node": ">=20.0.0" | ||
| }, | ||
| "exports": { | ||
| ".": { | ||
| "types": "./src/index.ts", | ||
| "default": "./src/index.ts" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "type-check": "tsc --noEmit", | ||
| "lint": "biome check --write --unsafe .", | ||
| "lint:check": "biome check .", | ||
| "format": "biome format --write .", | ||
| "format:check": "biome format .", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| }, | ||
| "dependencies": { | ||
| "@aws-sdk/client-secrets-manager": "3.1032.0", | ||
| "@sim/logger": "workspace:*", | ||
| "@sim/utils": "workspace:*" | ||
| }, | ||
| "devDependencies": { | ||
| "@sim/tsconfig": "workspace:*", | ||
| "@types/node": "24.2.1", | ||
| "typescript": "^5.7.3", | ||
| "vitest": "^4.1.0" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })) | ||
|
|
||
| vi.mock('@aws-sdk/client-secrets-manager', () => ({ | ||
| SecretsManagerClient: class SecretsManagerClient { | ||
| send = mockSend | ||
| }, | ||
| GetSecretValueCommand: class GetSecretValueCommand { | ||
| constructor(public input: unknown) {} | ||
| }, | ||
| })) | ||
|
|
||
| vi.mock('@sim/logger', () => ({ | ||
| createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), | ||
| })) | ||
|
|
||
| vi.mock('@sim/utils/helpers', () => ({ | ||
| sleep: vi.fn().mockResolvedValue(undefined), | ||
| })) | ||
|
|
||
| import { loadRuntimeSecrets } from './index' | ||
|
|
||
| const TOUCHED = ['SIM_ENV_SECRET_ID', 'FOO', 'BAZ'] as const | ||
|
|
||
| describe('loadRuntimeSecrets', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| for (const key of TOUCHED) delete process.env[key] | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| for (const key of TOUCHED) delete process.env[key] | ||
| }) | ||
|
|
||
| it('no-ops when SIM_ENV_SECRET_ID is unset', async () => { | ||
| await loadRuntimeSecrets() | ||
| expect(mockSend).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('hydrates process.env from the parsed secret JSON', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| mockSend.mockResolvedValue({ SecretString: JSON.stringify({ FOO: 'bar', BAZ: 'qux' }) }) | ||
|
|
||
| await loadRuntimeSecrets() | ||
|
|
||
| expect(process.env.FOO).toBe('bar') | ||
| expect(process.env.BAZ).toBe('qux') | ||
| }) | ||
|
|
||
| it('never overwrites an already-set env var', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| process.env.FOO = 'existing' | ||
| mockSend.mockResolvedValue({ SecretString: JSON.stringify({ FOO: 'new', BAZ: 'qux' }) }) | ||
|
|
||
| await loadRuntimeSecrets() | ||
|
|
||
| expect(process.env.FOO).toBe('existing') | ||
| expect(process.env.BAZ).toBe('qux') | ||
| }) | ||
|
|
||
| it('throws when the secret is not valid JSON', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| mockSend.mockResolvedValue({ SecretString: 'not json' }) | ||
|
|
||
| await expect(loadRuntimeSecrets()).rejects.toThrow(/not valid JSON/) | ||
| }) | ||
|
|
||
| it('throws when the secret JSON is not an object', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| mockSend.mockResolvedValue({ SecretString: JSON.stringify(['a', 'b']) }) | ||
|
|
||
| await expect(loadRuntimeSecrets()).rejects.toThrow(/must be a JSON object/) | ||
| }) | ||
|
|
||
| it('throws immediately on a binary secret (no SecretString), without retrying', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| mockSend.mockResolvedValue({}) | ||
|
|
||
| await expect(loadRuntimeSecrets()).rejects.toThrow(/binary secrets/) | ||
| expect(mockSend).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('retries then throws when the fetch keeps failing', async () => { | ||
| process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' | ||
| mockSend.mockRejectedValue(new Error('boom')) | ||
|
|
||
| await expect(loadRuntimeSecrets()).rejects.toThrow(/Failed to fetch runtime secrets/) | ||
| expect(mockSend).toHaveBeenCalledTimes(3) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import type { GetSecretValueCommandOutput } from '@aws-sdk/client-secrets-manager' | ||
| import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { sleep } from '@sim/utils/helpers' | ||
| import { backoffWithJitter } from '@sim/utils/retry' | ||
|
|
||
| const logger = createLogger('RuntimeSecrets') | ||
|
|
||
| /** Plaintext env var (set in the ECS task definition) naming the secret to ingest. */ | ||
| const SECRET_ID_ENV = 'SIM_ENV_SECRET_ID' | ||
|
|
||
| const MAX_ATTEMPTS = 3 | ||
|
|
||
| /** Bounds each Secrets Manager request so a stalled response can't hang boot. */ | ||
| const REQUEST_TIMEOUT_MS = 5000 | ||
|
|
||
| /** | ||
| * Fetches the combined `/{env}/sim/env-vars` secret once at container boot and | ||
| * hydrates `process.env`, so secrets no longer have to be fanned out into the | ||
| * ECS task definition (which is approaching the 64 KB rendered-document limit). | ||
| * | ||
| * Must run before any application module that reads env at import time. No-ops | ||
| * when {@link SECRET_ID_ENV} is unset (local dev / self-hosted keep using their | ||
| * own env). Existing `process.env` keys are never overwritten, so explicit | ||
| * task-definition `environment` entries win. Throws on any fetch/parse failure | ||
| * so a misconfigured container crashes instead of booting without its config. | ||
| */ | ||
| export async function loadRuntimeSecrets(): Promise<void> { | ||
| const secretId = process.env[SECRET_ID_ENV] | ||
| if (!secretId) { | ||
| logger.info(`${SECRET_ID_ENV} not set; skipping runtime secret ingestion`) | ||
| return | ||
| } | ||
|
|
||
| const client = new SecretsManagerClient( | ||
| process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} | ||
| ) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| const secretString = await fetchSecretString(client, secretId) | ||
| const entries = parseSecretJson(secretString) | ||
|
|
||
| let loaded = 0 | ||
| let skipped = 0 | ||
| for (const [key, value] of Object.entries(entries)) { | ||
| if (key in process.env) { | ||
| skipped++ | ||
| continue | ||
| } | ||
| process.env[key] = typeof value === 'string' ? value : JSON.stringify(value) | ||
| loaded++ | ||
| } | ||
|
|
||
| logger.info('Runtime secrets ingested', { secretId, loaded, skipped }) | ||
| } | ||
|
|
||
| async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise<string> { | ||
| const response = await sendWithRetry(client, secretId) | ||
| if (!response.SecretString) { | ||
| // Non-retriable: a binary secret will never become a string between attempts. | ||
| throw new Error('Secret has no SecretString (binary secrets are not supported)') | ||
| } | ||
| return response.SecretString | ||
| } | ||
|
|
||
| async function sendWithRetry( | ||
| client: SecretsManagerClient, | ||
| secretId: string | ||
| ): Promise<GetSecretValueCommandOutput> { | ||
| let lastError: unknown | ||
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | ||
| try { | ||
| return await client.send(new GetSecretValueCommand({ SecretId: secretId }), { | ||
| abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }) | ||
| } catch (error) { | ||
| lastError = error | ||
| if (attempt < MAX_ATTEMPTS) { | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| const delay = backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 2000 }) | ||
| logger.warn( | ||
| `Failed to fetch runtime secrets (attempt ${attempt}/${MAX_ATTEMPTS}), retrying`, | ||
| { error: getErrorMessage(error) } | ||
| ) | ||
| await sleep(delay) | ||
| } | ||
| } | ||
| } | ||
| throw new Error(`Failed to fetch runtime secrets from ${secretId}: ${getErrorMessage(lastError)}`) | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| function parseSecretJson(secretString: string): Record<string, unknown> { | ||
| let parsed: unknown | ||
| try { | ||
| parsed = JSON.parse(secretString) | ||
| } catch (error) { | ||
| throw new Error(`Runtime secret is not valid JSON: ${getErrorMessage(error)}`) | ||
| } | ||
| if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { | ||
| throw new Error('Runtime secret must be a JSON object of key/value pairs') | ||
| } | ||
| return parsed as Record<string, unknown> | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "extends": "@sim/tsconfig/library.json", | ||
| "include": ["src/**/*"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { defineConfig } from 'vitest/config' | ||
|
|
||
| export default defineConfig({ | ||
| test: { | ||
| environment: 'node', | ||
| }, | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.