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
6 changes: 3 additions & 3 deletions apps/sim/lib/core/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ export function getBaseUrl(): string {
* Whether this process is a Trigger.dev worker rather than the app container.
*
* `TRIGGER_SECRET_KEY` and `TRIGGER_DEV_ENABLED` are both present on the app too
* — it needs them to dispatch — so neither discriminates. `trigger.config.ts`
* syncs exactly one worker-only marker, `DB_APP_NAME='sim-trigger'`, which is
* what this reads.
* — it needs them to dispatch — so neither discriminates. Every Trigger.dev
* deploy sets `DB_APP_NAME='sim-trigger'` (`TRIGGER_CONSTANT_VARS` in
* `@sim/runtime-secrets/trigger-env`), which the app never has, so this reads it.
*/
function isTriggerWorkerRuntime(): boolean {
return getEnv('DB_APP_NAME') === 'sim-trigger'
Expand Down
47 changes: 35 additions & 12 deletions apps/sim/trigger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
import { createLogger } from '@sim/logger'
import {
diffTriggerEnv,
parseTriggerEnvSyncTargets,
resolveTriggerEnvFromAppSecret,
TRIGGER_CONSTANT_VARS,
} from '@sim/runtime-secrets/trigger-env'
Expand Down Expand Up @@ -46,7 +47,9 @@ if (grafanaConfigured && !grafanaFullyConfigured) {
* sandbox means loading its compiled artifact from the copilot storage
* context, so that bucket has to be visible to the run as well. The values
* still have to exist in the Trigger.dev environment; syncing only keeps the
* worker's view of them aligned with the app's.
* worker's view of them aligned with the app's. Only an environment outside
* `TRIGGER_ENV_SYNC_TARGETS` takes this list; a listed one gets these keys from
* the app's env-vars secret with everything else.
*/
const FUNCTION_EXECUTION_ENV = [
{ name: 'REDIS_URL', secret: true },
Expand All @@ -72,19 +75,37 @@ function getFunctionExecutionEnvVars() {
})
}

const syncLogger = createLogger('TriggerEnvSync')

/**
* The environments `TRIGGER_ENV_SYNC_TARGETS` lists. Exits on a list the deploy workflows would
* not match: `syncEnvVars` swallows a thrown error, and a typo must not silently keep the old path.
*/
function syncTargets(): Set<string> {
try {
return parseTriggerEnvSyncTargets(env.TRIGGER_ENV_SYNC_TARGETS)
} catch (error) {
syncLogger.error('Invalid TRIGGER_ENV_SYNC_TARGETS; aborting the deploy', {
error: getErrorMessage(error),
})
process.exit(1)
}
}

/**
* Mirrors the app's env-vars secret into an environment listed in
* `TRIGGER_ENV_SYNC_TARGETS`, logging only counts, never names or values (CI
* logs are public). Exits on failure: `syncEnvVars` catches a thrown error and
* deploys without syncing, and a failed deploy leaves the running version and
* its environment untouched where a silent skip would keep stale configuration.
* logs are public). The CLI applies the variables when it uploads the version,
* before promotion, and only adds or updates: a key removed from the secret
* stays in Trigger.dev until it is deleted there. Exits on failure, because
* `syncEnvVars` catches a thrown error and deploys without syncing, which would
* keep stale configuration.
*/
async function syncFromAppSecret(environment: string, deployedEnv: Record<string, string>) {
const logger = createLogger('TriggerEnvSync')
try {
const { secretId, vars } = await resolveTriggerEnvFromAppSecret(environment)
const { added, changed } = diffTriggerEnv(vars, deployedEnv)
logger.info('Syncing the Trigger.dev environment from the app env-vars secret', {
syncLogger.info('Syncing the Trigger.dev environment from the app env-vars secret', {
environment,
secretId,
total: vars.length,
Expand All @@ -93,7 +114,7 @@ async function syncFromAppSecret(environment: string, deployedEnv: Record<string
})
return vars
} catch (error) {
logger.error('Trigger.dev env sync failed; aborting the deploy', {
syncLogger.error('Trigger.dev env sync failed; aborting the deploy', {
environment,
error: getErrorMessage(error),
})
Expand Down Expand Up @@ -233,11 +254,13 @@ export default defineConfig({
],
extensions: [
nodeBinary(),
syncEnvVars(({ environment, env: deployedEnv }) =>
env.TRIGGER_ENV_SYNC_TARGETS?.split(',').includes(environment)
? syncFromAppSecret(environment, deployedEnv)
: [...TRIGGER_CONSTANT_VARS, ...getFunctionExecutionEnvVars()]
),
syncEnvVars(({ environment, env: deployedEnv }) => {
if (syncTargets().has(environment)) return syncFromAppSecret(environment, deployedEnv)
syncLogger.info('Syncing only the worker constants and function execution settings', {
environment,
})
return [...TRIGGER_CONSTANT_VARS, ...getFunctionExecutionEnvVars()]
}),
additionalFiles({
files: [
'./lib/execution/isolated-vm-worker.cjs',
Expand Down
13 changes: 13 additions & 0 deletions packages/runtime-secrets/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,17 @@ describe('loadRuntimeSecrets', () => {
await expect(loadRuntimeSecrets()).rejects.toThrow(/binary secrets/)
expect(mockSend).toHaveBeenCalledTimes(1)
})

it('keeps the secret text out of a malformed-JSON error, which deploy logs print', async () => {
process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars'
mockSend.mockResolvedValue({ SecretString: '{"FOO": postgres://user:hunter2@db/sim}' })

const error = await loadRuntimeSecrets().then(
() => undefined,
(caught: unknown) => caught
)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toMatch(/not valid JSON/)
expect((error as Error).message).not.toMatch(/postgres|hunter2|FOO/)
})
})
9 changes: 5 additions & 4 deletions packages/runtime-secrets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export async function loadRuntimeSecrets(): Promise<void> {
/**
* Reads one env-vars JSON secret as the string map a process would see: the
* same values {@link loadRuntimeSecrets} writes into `process.env`, with
* non-string JSON values serialized. Retries transient failures and throws on
* anything else, so a caller never proceeds on a partial or missing secret.
* non-string JSON values serialized. Retries a failed request up to three
* times, then throws, so a caller never proceeds on a partial or missing secret.
*/
export async function fetchRuntimeSecrets(secretId: string): Promise<Record<string, string>> {
const client = new SecretsManagerClient(
Expand Down Expand Up @@ -106,8 +106,9 @@ 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)}`)
} catch {
// The parser's message quotes the text around the fault, which can be part of a value.
throw new Error('Runtime secret is not valid JSON')
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime secret must be a JSON object of key/value pairs')
Expand Down
30 changes: 30 additions & 0 deletions packages/runtime-secrets/src/trigger-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { parseTriggerEnvSyncTargets } from './trigger-env'

describe('parseTriggerEnvSyncTargets', () => {
it('reads an exact comma list', () => {
expect([...parseTriggerEnvSyncTargets('preview,staging,prod')].sort()).toEqual([
'preview',
'prod',
'staging',
])
})

it('treats an unset or empty gate as no targets', () => {
expect(parseTriggerEnvSyncTargets(undefined).size).toBe(0)
expect(parseTriggerEnvSyncTargets('').size).toBe(0)
})

it.each([
'preview, staging',
'preview,,staging',
'preview,',
' ',
'preview,Staging',
'preview,production',
'constructor',
'preview,toString',
])('refuses %j, which the deploy workflows would not grant credentials for', (value) => {
expect(() => parseTriggerEnvSyncTargets(value)).toThrow(/TRIGGER_ENV_SYNC_TARGETS/)
})
})
23 changes: 22 additions & 1 deletion packages/runtime-secrets/src/trigger-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,30 @@ export function diffTriggerEnv(vars: TriggerEnvVar[], current: Record<string, st
}
}

/**
* Parses the `TRIGGER_ENV_SYNC_TARGETS` repository variable: an exact comma list of Trigger.dev
* environment slugs, such as `preview,staging,prod`. The deploy workflows decide whether to grant
* the secret-read credentials by matching that same exact list, so an entry they would not match
* (padded, empty, or a slug with no mapped secret) fails the deploy here rather than syncing
* without credentials or silently keeping the environment on its old configuration.
*/
export function parseTriggerEnvSyncTargets(value: string | undefined): Set<string> {
if (!value) return new Set()
const targets = value.split(',')
const invalid = targets.filter((target) => !Object.hasOwn(APP_ENV_SECRET_IDS, target))
if (invalid.length > 0) {
throw new Error(
`TRIGGER_ENV_SYNC_TARGETS must be a comma list of ${Object.keys(APP_ENV_SECRET_IDS).join(', ')} with no spaces; refused: ${invalid.map((target) => JSON.stringify(target)).join(', ')}`
)
}
return new Set(targets)
}

/** Reads the app env-vars secret mapped to `environment` and builds its Trigger.dev variables. */
export async function resolveTriggerEnvFromAppSecret(environment: string) {
const secretId = APP_ENV_SECRET_IDS[environment]
const secretId = Object.hasOwn(APP_ENV_SECRET_IDS, environment)
? APP_ENV_SECRET_IDS[environment]
: undefined
if (!secretId) {
throw new Error(`No app env-vars secret is mapped to Trigger.dev environment '${environment}'`)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/testing/src/mocks/urls.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function getInternalApiBaseUrlImpl(): string {
const internalBaseUrl = readEnv('INTERNAL_API_BASE_URL')?.trim()
// Mirrors the real module: the internal URL names a route that resolves only
// from inside the app container, so a Trigger.dev worker must ignore it.
// `DB_APP_NAME='sim-trigger'` is the worker-only marker trigger.config.ts syncs.
// `DB_APP_NAME='sim-trigger'` is the worker-only marker every Trigger.dev deploy sets.
if (!internalBaseUrl || readEnv('DB_APP_NAME') === 'sim-trigger') return getBaseUrlImpl()
if (!hasHttpProtocol(internalBaseUrl)) {
throw new Error(
Expand Down
Loading