diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 604fcf09072..705b2150f5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,9 @@ jobs: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 + permissions: + contents: read + id-token: write outputs: version: ${{ steps.deploy.outputs.deploymentVersion }} environment: ${{ steps.target.outputs.environment }} @@ -274,6 +277,15 @@ jobs: echo "environment=$TRIGGER_ENV" >> "$GITHUB_OUTPUT" echo "preview_branch=$TRIGGER_BRANCH" >> "$GITHUB_OUTPUT" + # The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's + # env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret. + - name: Configure AWS credentials for the env sync + if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), format(',{0},', steps.target.outputs.environment)) + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6 + with: + role-to-assume: ${{ github.ref == 'refs/heads/main' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ github.ref == 'refs/heads/main' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }} + - name: Upload Trigger.dev version without promotion id: deploy working-directory: ./apps/sim @@ -282,6 +294,7 @@ jobs: TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} TRIGGER_ENV: ${{ steps.target.outputs.environment }} TRIGGER_BRANCH: ${{ steps.target.outputs.preview_branch }} + TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }} run: | set -eo pipefail if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then diff --git a/.github/workflows/trigger-dev.yml b/.github/workflows/trigger-dev.yml index c34d6115764..9ce8cbbb7e8 100644 --- a/.github/workflows/trigger-dev.yml +++ b/.github/workflows/trigger-dev.yml @@ -19,6 +19,9 @@ jobs: name: deploy runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 + permissions: + contents: read + id-token: write steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -31,12 +34,22 @@ jobs: provider: ${{ vars.CI_PROVIDER }} node-version: '' + # The deploy syncs an environment listed in `vars.TRIGGER_ENV_SYNC_TARGETS` from the app's + # env-vars secret (see `@sim/runtime-secrets/trigger-env`), which needs read access to that secret. + - name: Configure AWS credentials for the env sync + if: contains(format(',{0},', vars.TRIGGER_ENV_SYNC_TARGETS), ',preview,') + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6 + with: + role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }} + aws-region: ${{ secrets.DEV_AWS_REGION }} + - name: Upload preview version id: deploy working-directory: ./apps/sim env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN_PREVIEW || secrets.TRIGGER_ACCESS_TOKEN }} TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} + TRIGGER_ENV_SYNC_TARGETS: ${{ vars.TRIGGER_ENV_SYNC_TARGETS }} run: | set -euo pipefail : "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}" diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 1af9bdf88ba..dbc48cf9418 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -346,6 +346,7 @@ export const env = createEnv({ TRIGGER_PROJECT_ID: z.string().optional(), // Trigger.dev project ID TRIGGER_SECRET_KEY: z.string().min(1).optional(), // Trigger.dev secret key for background jobs TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs + TRIGGER_ENV_SYNC_TARGETS: z.string().optional(), // Deploy-time only: comma-separated Trigger.dev environments (prod,staging,preview) synced from the app's env-vars secret CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests SCHEDULE_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('30'), WORKFLOW_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('75'), diff --git a/apps/sim/scripts/plan-trigger-env-sync.ts b/apps/sim/scripts/plan-trigger-env-sync.ts new file mode 100644 index 00000000000..f6100cfeeb5 --- /dev/null +++ b/apps/sim/scripts/plan-trigger-env-sync.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env bun + +/** + * Shows what syncing a Trigger.dev environment from the app's env-vars secret + * would change, by variable name only, before the sync is enabled for it. + * + * Reads the app secret, builds the variables the deploy would sync (the same + * `buildTriggerEnvVars` the `syncEnvVars` extension uses), and compares them + * with the values the environment holds now, read through the environment's + * API key exactly as the deploy CLI reads them. Never prints a value. + * + * The deploy only sends variables whose value changed, so an unchanged + * variable stored as plain text stays plain. `--apply-secret-flags` marks + * those as secrets with their current value, which is otherwise untouched. + * + * Usage: + * AWS_PROFILE= bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts staging + * AWS_PROFILE= bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts preview + * AWS_PROFILE= bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts prod --apply-secret-flags + */ + +import { createLogger } from '@sim/logger' +import { fetchRuntimeSecrets } from '@sim/runtime-secrets' +import { + APP_ENV_SECRET_IDS, + buildTriggerEnvVars, + diffTriggerEnv, +} from '@sim/runtime-secrets/trigger-env' +import { getErrorMessage } from '@sim/utils/errors' +import { chunkArray } from '@sim/utils/helpers' + +const logger = createLogger('PlanTriggerEnvSync') + +const TRIGGER_API_URL = 'https://api.trigger.dev' + +/** Variables per import call when flipping secret flags. */ +const IMPORT_BATCH_SIZE = 50 + +interface ListedVariable { + name: string + isSecret?: boolean +} + +async function main() { + const environment = process.argv[2] ?? '' + const applySecretFlags = process.argv.includes('--apply-secret-flags') + const secretId = APP_ENV_SECRET_IDS[environment] + if (!secretId) { + throw new Error( + `Usage: plan-trigger-env-sync.ts <${Object.keys(APP_ENV_SECRET_IDS).join('|')}> [--apply-secret-flags]` + ) + } + + const appEnv = await fetchRuntimeSecrets(secretId) + const { TRIGGER_PROJECT_ID: project, TRIGGER_SECRET_KEY: apiKey } = appEnv + if (!project || !apiKey) { + throw new Error(`${secretId} has no TRIGGER_PROJECT_ID/TRIGGER_SECRET_KEY`) + } + const branch = environment === 'preview' ? appEnv.TRIGGER_PREVIEW_BRANCH : undefined + if (environment === 'preview' && !branch) { + throw new Error(`${secretId} has no TRIGGER_PREVIEW_BRANCH`) + } + + const headers = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...(branch ? { 'x-trigger-branch': branch } : {}), + } + const envvarsUrl = `${TRIGGER_API_URL}/api/v1/projects/${project}/envvars` + async function request(url: string, body?: unknown): Promise { + const response = await fetch(url, { + method: body === undefined ? 'GET' : 'POST', + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) throw new Error(`${url} failed with ${response.status}`) + return (await response.json()) as T + } + async function listPlainNames(): Promise> { + const listed = await request( + `${envvarsUrl}/${environment}` + ) + const variables = Array.isArray(listed) ? listed : listed.data + return new Set(variables.filter((v) => !v.isSecret).map((v) => v.name)) + } + + const [{ variables: current }, plainNames] = await Promise.all([ + request<{ variables: Record }>(envvarsUrl), + listPlainNames(), + ]) + + const synced = buildTriggerEnvVars(appEnv) + const syncedNames = new Set(synced.map((v) => v.name)) + const { added, changed } = diffTriggerEnv(synced, current) + const toFlag = synced.filter( + (v) => v.isSecret && plainNames.has(v.name) && current[v.name] === v.value + ) + const names = (vars: { name: string }[]) => vars.map((v) => v.name).sort() + + logger.info('Trigger.dev env sync plan', { + environment, + branch, + secretId, + synced: synced.length, + unchanged: synced.length - added.length - changed.length, + added: names(added), + changed: names(changed), + plainToSecretOnSync: names(changed.filter((v) => v.isSecret && plainNames.has(v.name))), + plainUnchangedNeedingSecretFlag: names(toFlag), + appKeysNotSynced: Object.keys(appEnv) + .filter((name) => !syncedNames.has(name)) + .sort(), + }) + + if (!applySecretFlags || toFlag.length === 0) return + // The import resubmits each value, so re-read right before each batch and skip any variable + // rotated since the plan: flagging must never write back an older value. + const skipped: string[] = [] + for (const batch of chunkArray(toFlag, IMPORT_BATCH_SIZE)) { + const { variables: latest } = await request<{ variables: Record }>(envvarsUrl) + const unrotated = batch.filter((v) => latest[v.name] === v.value) + skipped.push(...batch.filter((v) => latest[v.name] !== v.value).map((v) => v.name)) + if (unrotated.length === 0) continue + await request(`${envvarsUrl}/${environment}/import`, { + variables: Object.fromEntries(unrotated.map((v) => [v.name, v.value])), + override: true, + isSecret: true, + }) + } + const stillPlain = await listPlainNames() + const unflagged = names(toFlag.filter((v) => stillPlain.has(v.name) && !skipped.includes(v.name))) + if (unflagged.length > 0) throw new Error(`Still plain after flagging: ${unflagged.join(', ')}`) + logger.info('Marked unchanged variables as secrets', { + environment, + flagged: toFlag.length - skipped.length, + skippedAsRotated: skipped.sort(), + }) +} + +main().catch((error) => { + logger.error('Trigger.dev env sync plan failed', { error: getErrorMessage(error) }) + process.exit(1) +}) diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 85e7ba6edf2..9d4211ac54a 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -3,7 +3,13 @@ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { resourceFromAttributes } from '@opentelemetry/resources' import { createLogger } from '@sim/logger' +import { + diffTriggerEnv, + resolveTriggerEnvFromAppSecret, + TRIGGER_CONSTANT_VARS, +} from '@sim/runtime-secrets/trigger-env' import { distrustCompromisedRoots } from '@sim/security/tls-trust' +import { getErrorMessage } from '@sim/utils/errors' import type { BuildExtension } from '@trigger.dev/build' import { additionalFiles, @@ -66,6 +72,35 @@ function getFunctionExecutionEnvVars() { }) } +/** + * 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. + */ +async function syncFromAppSecret(environment: string, deployedEnv: Record) { + 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', { + environment, + secretId, + total: vars.length, + added: added.length, + changed: changed.length, + }) + return vars + } catch (error) { + logger.error('Trigger.dev env sync failed; aborting the deploy', { + environment, + error: getErrorMessage(error), + }) + process.exit(1) + } +} + /** * Node.js version copied into the task image, in lockstep with `NODE_VERSION` * in `docker/app.Dockerfile`: `isolated-vm` 6.x loads its Node 24 (ABI 137) @@ -198,17 +233,11 @@ export default defineConfig({ ], extensions: [ nodeBinary(), - syncEnvVars(() => [ - { name: 'DB_APP_NAME', value: 'sim-trigger' }, - /** - * Workers run Trigger.dev by definition, but the flag saying so was only - * set on the app container. Syncing it keeps the deployment flag honest - * inside runs; the dispatch decision itself no longer depends on it, - * because the `init` hook above marks the run process directly. - */ - { name: 'TRIGGER_DEV_ENABLED', value: 'TRUE' }, - ...getFunctionExecutionEnvVars(), - ]), + syncEnvVars(({ environment, env: deployedEnv }) => + env.TRIGGER_ENV_SYNC_TARGETS?.split(',').includes(environment) + ? syncFromAppSecret(environment, deployedEnv) + : [...TRIGGER_CONSTANT_VARS, ...getFunctionExecutionEnvVars()] + ), additionalFiles({ files: [ './lib/execution/isolated-vm-worker.cjs', diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json index 57385c7e921..7d32251256d 100644 --- a/packages/runtime-secrets/package.json +++ b/packages/runtime-secrets/package.json @@ -13,6 +13,10 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./trigger-env": { + "types": "./src/trigger-env.ts", + "default": "./src/trigger-env.ts" } }, "scripts": { diff --git a/packages/runtime-secrets/src/index.ts b/packages/runtime-secrets/src/index.ts index 86c79e7952d..abd0dec51bf 100644 --- a/packages/runtime-secrets/src/index.ts +++ b/packages/runtime-secrets/src/index.ts @@ -33,12 +33,7 @@ export async function loadRuntimeSecrets(): Promise { return } - const client = new SecretsManagerClient( - process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} - ) - - const secretString = await fetchSecretString(client, secretId) - const entries = parseSecretJson(secretString) + const entries = await fetchRuntimeSecrets(secretId) let loaded = 0 let skipped = 0 @@ -47,13 +42,32 @@ export async function loadRuntimeSecrets(): Promise { skipped++ continue } - process.env[key] = typeof value === 'string' ? value : JSON.stringify(value) + process.env[key] = value loaded++ } logger.info('Runtime secrets ingested', { secretId, loaded, skipped }) } +/** + * 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. + */ +export async function fetchRuntimeSecrets(secretId: string): Promise> { + const client = new SecretsManagerClient( + process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} + ) + const entries = parseSecretJson(await fetchSecretString(client, secretId)) + return Object.fromEntries( + Object.entries(entries).map(([key, value]) => [ + key, + typeof value === 'string' ? value : JSON.stringify(value), + ]) + ) +} + async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise { const response = await sendWithRetry(client, secretId) if (!response.SecretString) { diff --git a/packages/runtime-secrets/src/trigger-env.ts b/packages/runtime-secrets/src/trigger-env.ts new file mode 100644 index 00000000000..46bd1d31892 --- /dev/null +++ b/packages/runtime-secrets/src/trigger-env.ts @@ -0,0 +1,85 @@ +/** + * The app's env-vars secret each Trigger.dev environment mirrors, keyed by the + * environment slug a deploy targets. Pinned here rather than passed in, so a + * deploy can never sync one environment's configuration into another. + */ +export const APP_ENV_SECRET_IDS: Record = { + prod: '/production/sim/env-vars', + staging: '/staging/sim/env-vars', + preview: '/dev/sim/env-vars', +} + +/** Values every Trigger.dev environment runs with, overriding the app secret. */ +export const TRIGGER_CONSTANT_VARS = [ + { name: 'DB_APP_NAME', value: 'sim-trigger', isSecret: false }, + { name: 'SIM_DB_ROLE', value: 'trigger', isSecret: false }, +] + +/** + * App keys whose Trigger.dev value must differ from the app's, or that a run + * must never hold. The sync leaves them out entirely, so the value already set + * in the Trigger.dev environment (if any) stays as it is. + */ +const TRIGGER_OWNED_KEYS: ReadonlySet = new Set([ + // Workers run outside the VPC and reach Redis through their own endpoint. + 'REDIS_URL', + 'REDIS_TLS_SERVERNAME', + // A VPC-internal base URL is unreachable from Trigger.dev's cloud. + 'INTERNAL_API_BASE_URL', + // Schema-migration credential: runs never migrate, so they never hold it. + 'MIGRATION_DATABASE_URL', + // `syncEnvVars` refuses to sync it, so it can only ever be set by hand. + 'GITHUB_TOKEN', +]) + +const TRIGGER_OWNED_PREFIXES = [ + // Runs authenticate to AWS through workload identity, never static keys. + 'AWS_', + // Trigger.dev configures each run's OTLP exporter; the app's collector is not the run's. + 'OTEL_EXPORTER_OTLP_', + // The platform owns these, and `syncEnvVars` drops the prefix anyway. + 'TRIGGER_', +] as const + +interface TriggerEnvVar { + name: string + value: string + isSecret: boolean +} + +function isTriggerOwned(key: string): boolean { + return TRIGGER_OWNED_KEYS.has(key) || TRIGGER_OWNED_PREFIXES.some((p) => key.startsWith(p)) +} + +/** + * The variables a Trigger.dev environment syncs from the app's env-vars + * secret: every app key except the Trigger-owned ones, then the Trigger + * constants. App values are secrets unless the key is a `NEXT_PUBLIC_*` value + * the browser already receives. + */ +export function buildTriggerEnvVars(appEnv: Record): TriggerEnvVar[] { + const constantNames = new Set(TRIGGER_CONSTANT_VARS.map((v) => v.name)) + const fromApp = Object.entries(appEnv) + .filter(([name]) => !isTriggerOwned(name) && !constantNames.has(name)) + .map(([name, value]) => ({ name, value, isSecret: !name.startsWith('NEXT_PUBLIC_') })) + if (fromApp.length === 0) throw new Error('The app env-vars secret has no variables to sync') + return [...fromApp, ...TRIGGER_CONSTANT_VARS] +} + +/** Splits `vars` into those `current` lacks and those it holds with another value. */ +export function diffTriggerEnv(vars: TriggerEnvVar[], current: Record) { + return { + added: vars.filter((v) => !(v.name in current)), + changed: vars.filter((v) => v.name in current && current[v.name] !== v.value), + } +} + +/** 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] + if (!secretId) { + throw new Error(`No app env-vars secret is mapped to Trigger.dev environment '${environment}'`) + } + const { fetchRuntimeSecrets } = await import('./index') + return { secretId, vars: buildTriggerEnvVars(await fetchRuntimeSecrets(secretId)) } +}