|
| 1 | +import { createLogger } from '@sim/logger' |
| 2 | +import { fetchSecretMap } from '@sim/runtime-secrets' |
| 3 | +import { getErrorMessage } from '@sim/utils/errors' |
| 4 | + |
| 5 | +const logger = createLogger('TriggerEnvSync') |
| 6 | + |
| 7 | +/** |
| 8 | + * One variable to publish into the Trigger.dev environment being deployed. |
| 9 | + * Mirrors the shape `syncEnvVars` accepts. |
| 10 | + */ |
| 11 | +export interface SyncedEnvVar { |
| 12 | + name: string |
| 13 | + value: string |
| 14 | + isSecret: boolean |
| 15 | +} |
| 16 | + |
| 17 | +/** |
| 18 | + * Values that are the same in every environment, so they need no secret lookup |
| 19 | + * and are published even when the lookup fails. |
| 20 | + * |
| 21 | + * Nothing here may start with `TRIGGER_`: `syncEnvVars` drops every key with |
| 22 | + * that prefix before it builds its layer, so such an entry looks published and |
| 23 | + * never is. `TRIGGER_DEV_ENABLED` used to sit in this list for exactly that |
| 24 | + * reason and had no effect for the life of the config; workers that need it get |
| 25 | + * it from the Trigger.dev dashboard, and run dispatch does not read it at all |
| 26 | + * because the `init` hook in `trigger.config.ts` marks the run process directly. |
| 27 | + */ |
| 28 | +const CONSTANT_ENV: readonly SyncedEnvVar[] = [ |
| 29 | + { name: 'DB_APP_NAME', value: 'sim-trigger', isSecret: false }, |
| 30 | +] as const |
| 31 | + |
| 32 | +/** Prefix `syncEnvVars` strips from any key it is handed. */ |
| 33 | +const UNSYNCABLE_PREFIX = 'TRIGGER_' |
| 34 | + |
| 35 | +/** |
| 36 | + * Environment a run needs for sandboxed work. Function block runs and the |
| 37 | + * document compiler share one provider selection, and the doc-template |
| 38 | + * variables decide whether a run reads a generated document through the doc |
| 39 | + * sandbox's artifact store or the isolated-vm fallback. The app authors |
| 40 | + * documents for whichever compiler it sees, so a worker missing the doc |
| 41 | + * template falls back to isolated-vm and tries to run Python or Node-style |
| 42 | + * sources as sandbox JavaScript. Reading a generated document under the doc |
| 43 | + * sandbox means loading its compiled artifact from the copilot storage |
| 44 | + * context, so that bucket has to be visible to the run as well. |
| 45 | + * |
| 46 | + * To give workers a new variable, add its key here and set it in the |
| 47 | + * `/{env}/sim/env-vars` secret. The next deploy publishes it; nothing has to be |
| 48 | + * entered in the Trigger.dev dashboard by hand. |
| 49 | + */ |
| 50 | +export const WORKER_SECRET_KEYS: readonly { name: string; secret: boolean }[] = [ |
| 51 | + { name: 'REDIS_URL', secret: true }, |
| 52 | + { name: 'REDIS_TLS_SERVERNAME', secret: false }, |
| 53 | + { name: 'SANDBOX_PROVIDER', secret: false }, |
| 54 | + { name: 'E2B_ENABLED', secret: false }, |
| 55 | + { name: 'E2B_API_KEY', secret: true }, |
| 56 | + { name: 'E2B_FUNCTION_TEMPLATE_ID', secret: false }, |
| 57 | + { name: 'E2B_FUNCTION_TEMPLATE_GENERATION', secret: false }, |
| 58 | + { name: 'MOTHERSHIP_E2B_DOC_TEMPLATE_ID', secret: false }, |
| 59 | + { name: 'DAYTONA_API_KEY', secret: true }, |
| 60 | + { name: 'DAYTONA_FUNCTION_SNAPSHOT_ID', secret: false }, |
| 61 | + { name: 'DAYTONA_DOC_SNAPSHOT_ID', secret: false }, |
| 62 | + { name: 'S3_COPILOT_BUCKET_NAME', secret: false }, |
| 63 | + { name: 'AZURE_STORAGE_COPILOT_CONTAINER_NAME', secret: false }, |
| 64 | + { name: 'GCS_COPILOT_BUCKET_NAME', secret: false }, |
| 65 | +] as const |
| 66 | + |
| 67 | +/** |
| 68 | + * Secret backing each Trigger.dev deploy target. `preview` is the `dev-sim` |
| 69 | + * branch CI deploys from the `dev` branch, so it reads the dev environment's |
| 70 | + * secret. A target absent from this map gets the constants and nothing else — |
| 71 | + * guessing a secret would risk publishing one environment's credentials into |
| 72 | + * another, which `syncEnvVars` would then apply with `override: true`. |
| 73 | + */ |
| 74 | +export const SECRET_ID_BY_ENVIRONMENT: Readonly<Record<string, string>> = { |
| 75 | + prod: '/production/sim/env-vars', |
| 76 | + staging: '/staging/sim/env-vars', |
| 77 | + preview: '/dev/sim/env-vars', |
| 78 | +} as const |
| 79 | + |
| 80 | +/** |
| 81 | + * Resolves the variables to publish into one Trigger.dev environment, reading |
| 82 | + * them from the same Secrets Manager entry the app container boots from, so the |
| 83 | + * two runtimes cannot drift. |
| 84 | + * |
| 85 | + * Never throws. `syncEnvVars` swallows a callback rejection and then publishes |
| 86 | + * *nothing*, which would silently drop the constants too, so a failed or |
| 87 | + * incomplete lookup degrades to publishing what is known rather than aborting. |
| 88 | + * Keys the secret does not carry are logged by name and simply not published — |
| 89 | + * several are optional (Azure and GCS buckets on an S3 deployment, for one), so |
| 90 | + * their absence is normal rather than an error, and leaving them out preserves |
| 91 | + * whatever the Trigger.dev environment already held. |
| 92 | + * |
| 93 | + * @param environment Trigger.dev deploy target (`prod`, `staging`, `preview`). |
| 94 | + * @param loadSecret Secret reader, injectable for tests. |
| 95 | + */ |
| 96 | +export async function resolveTriggerEnvVars( |
| 97 | + environment: string, |
| 98 | + loadSecret: (secretId: string) => Promise<Record<string, unknown>> = fetchSecretMap |
| 99 | +): Promise<SyncedEnvVar[]> { |
| 100 | + const secretId = SECRET_ID_BY_ENVIRONMENT[environment] |
| 101 | + if (!secretId) { |
| 102 | + logger.warn( |
| 103 | + `No secret mapped for Trigger.dev environment "${environment}"; publishing constants only` |
| 104 | + ) |
| 105 | + return [...CONSTANT_ENV] |
| 106 | + } |
| 107 | + |
| 108 | + let entries: Record<string, unknown> |
| 109 | + try { |
| 110 | + entries = await loadSecret(secretId) |
| 111 | + } catch (error) { |
| 112 | + logger.error( |
| 113 | + `Failed to read ${secretId}; publishing constants only. Worker env is unchanged from the previous deploy.`, |
| 114 | + { error: getErrorMessage(error) } |
| 115 | + ) |
| 116 | + return [...CONSTANT_ENV] |
| 117 | + } |
| 118 | + |
| 119 | + const resolved: SyncedEnvVar[] = [...CONSTANT_ENV] |
| 120 | + const missing: string[] = [] |
| 121 | + |
| 122 | + for (const { name, secret } of WORKER_SECRET_KEYS) { |
| 123 | + const value = normalizeSecretValue(entries[name]) |
| 124 | + if (value === undefined) { |
| 125 | + missing.push(name) |
| 126 | + continue |
| 127 | + } |
| 128 | + resolved.push({ name, value, isSecret: secret }) |
| 129 | + } |
| 130 | + |
| 131 | + if (missing.length > 0) { |
| 132 | + logger.info( |
| 133 | + `${missing.length} worker env var(s) not set in ${secretId}; they are left untouched in Trigger.dev`, |
| 134 | + { missing } |
| 135 | + ) |
| 136 | + } |
| 137 | + |
| 138 | + logger.info('Resolved Trigger.dev env vars', { |
| 139 | + environment, |
| 140 | + secretId, |
| 141 | + published: resolved.length, |
| 142 | + missing: missing.length, |
| 143 | + }) |
| 144 | + |
| 145 | + return resolved |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * Coerces a secret entry to an env var value, matching how container boot |
| 150 | + * hydrates `process.env`. An absent or empty value is treated as unset so a |
| 151 | + * blank secret entry cannot overwrite a working dashboard value with `''`. |
| 152 | + */ |
| 153 | +function normalizeSecretValue(value: unknown): string | undefined { |
| 154 | + if (value === undefined || value === null) return undefined |
| 155 | + const normalized = typeof value === 'string' ? value : JSON.stringify(value) |
| 156 | + return normalized === '' ? undefined : normalized |
| 157 | +} |
| 158 | + |
| 159 | +/** |
| 160 | + * Guards the one silent failure this module cannot otherwise surface: a key the |
| 161 | + * sync layer strips is indistinguishable, in the deploy log, from one it |
| 162 | + * published. |
| 163 | + * |
| 164 | + * Called at module load rather than per resolve, so a key added to the wrong |
| 165 | + * list fails config evaluation — and therefore the deploy and the test run — |
| 166 | + * instead of reaching {@link resolveTriggerEnvVars}, whose contract is to |
| 167 | + * degrade rather than throw. |
| 168 | + */ |
| 169 | +export function assertSyncableKeys( |
| 170 | + names: readonly string[] = [ |
| 171 | + ...CONSTANT_ENV.map((v) => v.name), |
| 172 | + ...WORKER_SECRET_KEYS.map((k) => k.name), |
| 173 | + ] |
| 174 | +): void { |
| 175 | + const stripped = names.filter((name) => name.startsWith(UNSYNCABLE_PREFIX)) |
| 176 | + |
| 177 | + if (stripped.length > 0) { |
| 178 | + throw new Error( |
| 179 | + `syncEnvVars strips ${UNSYNCABLE_PREFIX}-prefixed keys, so these can never reach a worker and must be set in the Trigger.dev dashboard: ${stripped.join(', ')}` |
| 180 | + ) |
| 181 | + } |
| 182 | +} |
| 183 | + |
| 184 | +assertSyncableKeys() |
0 commit comments