diff --git a/.gitignore b/.gitignore index bc6aa6b..eaa1976 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules .env .env.* +!env/.env.shared.example +env/.env.shared dist coverage *.log diff --git a/README.md b/README.md index c4a0160..67ec34e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,20 @@ npm run dev:worker npm run dev:stack ``` -The API and worker each load dotenv from their own workspace directory. Put the API values in `apps/tracking-api/.env`, and put the worker values in `apps/router-worker/.env` if you are not exporting them in your shell. +The API and worker each load dotenv from their own workspace directory. This repo now supports a shared source file and generated per-workspace env files: + +```bash +cp env/.env.shared.example env/.env.shared +npm run env:check +npm run env:sync +``` + +`npm run env:sync` generates: + +- `apps/tracking-api/.env` +- `apps/router-worker/.env` +- `deploy/app/.env` +- `deploy/infra/.env` Minimum local queue settings: diff --git a/docs/07-ops/runbooks.md b/docs/07-ops/runbooks.md index b04fb5c..753d8c7 100644 --- a/docs/07-ops/runbooks.md +++ b/docs/07-ops/runbooks.md @@ -66,9 +66,19 @@ Use this when you want to run the queue path locally end to end. ### Environment layout -- `apps/tracking-api/.env` is loaded when you run the API workspace script -- `apps/router-worker/.env` is loaded when you run the worker workspace script -- the two apps do not read a shared repo-root `.env` file by default, so copy the values into each workspace that needs them +- Shared source of truth: `env/.env.shared` (copy from `env/.env.shared.example`) +- Generate all runtime/deploy env files with: + +```bash +npm run env:check +npm run env:sync +``` + +- Generated files: + - `apps/tracking-api/.env` + - `apps/router-worker/.env` + - `deploy/app/.env` + - `deploy/infra/.env` Recommended minimum values: diff --git a/env/.env.shared.example b/env/.env.shared.example new file mode 100644 index 0000000..c5efbd4 --- /dev/null +++ b/env/.env.shared.example @@ -0,0 +1,43 @@ +# Shared source of truth for env values. +# Copy to env/.env.shared, fill required values, then run: npm run env:sync + +# --- API local runtime --- +PORT=3000 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/tracking_base +TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/tracking_base +NODE_ENV=development +METRICS_ENABLED=true +TRACKING_API_AUTH_MODE=off +TRACKING_API_SECRET= +TRACKING_SIGNATURE_SKEW_SECONDS=300 +TRACKING_RATE_LIMIT_ENABLED=false +TRACKING_RATE_LIMIT_WINDOW_MS=60000 +TRACKING_RATE_LIMIT_MAX_REQUESTS=120 +TRACKING_CORS_ALLOW_ORIGINS=http://localhost:5173,https://prankbook.com,https://staging.prankbook.com/ + +# --- Queue runtime (shared by API + worker) --- +REDIS_URL=redis://localhost:6379/0 +REDIS_HOST= +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_USERNAME= +REDIS_PASSWORD= +ROUTER_QUEUE_NAME=router-deliveries +ROUTER_WORKER_NAME=router-delivery-worker +ROUTER_WORKER_CONCURRENCY=5 + +# --- Worker destination adapters (optional) --- +META_CONVERSIONS_ENDPOINT_URL= +META_PIXEL_ID= +META_ACCESS_TOKEN= +META_TEST_EVENT_CODE= +GOOGLE_CONVERSIONS_ENDPOINT_URL= +GOOGLE_CONVERSIONS_API_KEY= +TIKTOK_EVENTS_ENDPOINT_URL= +TIKTOK_ACCESS_TOKEN= + +# --- Deploy app stack --- +TRACKING_API_IMAGE=ghcr.io/congthiendev/tracking_base_system/tracking-api:latest +ROUTER_WORKER_IMAGE=ghcr.io/congthiendev/tracking_base_system/router-worker:latest +TRACKING_API_PORT=3000 +ADMIN_API_TOKEN= diff --git a/package.json b/package.json index 67fc9cf..b6c6693 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "preview:console": "npm run -w @tracking-base/tracking-console preview", "test": "npm run -w @tracking-base/tracking-api test", "test:shared-contracts": "npm run -w @tracking-base/shared-contracts test", + "env:sync": "node scripts/env-sync.mjs sync", + "env:check": "node scripts/env-sync.mjs check", "migrate": "npm run -w @tracking-base/tracking-api migrate", "validate:delete-by-user": "npm run -w @tracking-base/tracking-api validate:delete-by-user", "parity:check": "npm run -w @tracking-base/tracking-api parity:check --", diff --git a/scripts/env-sync.mjs b/scripts/env-sync.mjs new file mode 100644 index 0000000..d20510d --- /dev/null +++ b/scripts/env-sync.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const sharedEnvPath = path.join(repoRoot, 'env', '.env.shared'); +const sharedEnvExamplePath = path.join(repoRoot, 'env', '.env.shared.example'); + +const OUTPUT_HEADER = '# Generated by scripts/env-sync.mjs from env/.env.shared\n'; + +const targets = [ + { + name: 'tracking-api', + outputPath: path.join(repoRoot, 'apps', 'tracking-api', '.env'), + keys: [ + 'PORT', + 'DATABASE_URL', + 'TEST_DATABASE_URL', + 'REDIS_URL', + 'ROUTER_QUEUE_NAME', + 'ROUTER_WORKER_NAME', + 'ROUTER_WORKER_CONCURRENCY', + 'REDIS_HOST', + 'REDIS_PORT', + 'REDIS_DB', + 'REDIS_USERNAME', + 'REDIS_PASSWORD', + 'NODE_ENV', + 'METRICS_ENABLED', + 'TRACKING_API_AUTH_MODE', + 'TRACKING_API_SECRET', + 'TRACKING_SIGNATURE_SKEW_SECONDS', + 'TRACKING_RATE_LIMIT_ENABLED', + 'TRACKING_RATE_LIMIT_WINDOW_MS', + 'TRACKING_RATE_LIMIT_MAX_REQUESTS', + 'TRACKING_CORS_ALLOW_ORIGINS' + ], + required: ['PORT', 'DATABASE_URL', 'TRACKING_API_AUTH_MODE'] + }, + { + name: 'router-worker', + outputPath: path.join(repoRoot, 'apps', 'router-worker', '.env'), + keys: [ + 'REDIS_URL', + 'DATABASE_URL', + 'ROUTER_QUEUE_NAME', + 'ROUTER_WORKER_NAME', + 'ROUTER_WORKER_CONCURRENCY', + 'META_CONVERSIONS_ENDPOINT_URL', + 'META_PIXEL_ID', + 'META_ACCESS_TOKEN', + 'META_TEST_EVENT_CODE', + 'GOOGLE_CONVERSIONS_ENDPOINT_URL', + 'GOOGLE_CONVERSIONS_API_KEY', + 'TIKTOK_EVENTS_ENDPOINT_URL', + 'TIKTOK_ACCESS_TOKEN' + ], + required: ['REDIS_URL', 'ROUTER_QUEUE_NAME', 'ROUTER_WORKER_NAME', 'ROUTER_WORKER_CONCURRENCY'] + }, + { + name: 'deploy-app', + outputPath: path.join(repoRoot, 'deploy', 'app', '.env'), + keys: [ + 'TRACKING_API_IMAGE', + 'ROUTER_WORKER_IMAGE', + 'TRACKING_API_PORT', + 'DATABASE_URL', + 'ADMIN_API_TOKEN', + 'REDIS_URL', + 'ROUTER_QUEUE_NAME', + 'ROUTER_WORKER_NAME', + 'ROUTER_WORKER_CONCURRENCY', + 'TRACKING_API_AUTH_MODE', + 'TRACKING_API_SECRET', + 'TRACKING_SIGNATURE_SKEW_SECONDS', + 'TRACKING_RATE_LIMIT_ENABLED', + 'TRACKING_RATE_LIMIT_WINDOW_MS', + 'TRACKING_RATE_LIMIT_MAX_REQUESTS', + 'TRACKING_CORS_ALLOW_ORIGINS', + 'METRICS_ENABLED', + 'META_CONVERSIONS_ENDPOINT_URL', + 'META_PIXEL_ID', + 'META_ACCESS_TOKEN', + 'META_TEST_EVENT_CODE', + 'GOOGLE_CONVERSIONS_ENDPOINT_URL', + 'GOOGLE_CONVERSIONS_API_KEY', + 'TIKTOK_EVENTS_ENDPOINT_URL', + 'TIKTOK_ACCESS_TOKEN' + ], + required: ['TRACKING_API_IMAGE', 'ROUTER_WORKER_IMAGE', 'DATABASE_URL', 'REDIS_URL'] + }, + { + name: 'deploy-infra', + outputPath: path.join(repoRoot, 'deploy', 'infra', '.env'), + keys: ['REDIS_PORT'], + required: ['REDIS_PORT'] + } +]; + +function stripInlineComment(value) { + const index = value.search(/\s+#/); + if (index === -1) { + return value; + } + return value.slice(0, index).trimEnd(); +} + +function parseEnv(content) { + const values = new Map(); + const lines = content.split(/\r?\n/); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const normalizedLine = line.startsWith('export ') ? line.slice(7).trimStart() : line; + const eqIndex = normalizedLine.indexOf('='); + if (eqIndex <= 0) { + continue; + } + + const key = normalizedLine.slice(0, eqIndex).trim(); + let value = normalizedLine.slice(eqIndex + 1); + + if (!/^[_A-Za-z][_A-Za-z0-9]*$/.test(key)) { + continue; + } + + const trimmedValue = value.trim(); + if ( + (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) || + (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")) + ) { + value = trimmedValue; + } else { + value = stripInlineComment(value).trim(); + } + + values.set(key, value); + } + + return values; +} + +function loadSharedValues() { + if (!fs.existsSync(sharedEnvPath)) { + const hint = fs.existsSync(sharedEnvExamplePath) + ? 'Copy env/.env.shared.example to env/.env.shared and fill values first.' + : 'Create env/.env.shared first.'; + throw new Error(`Missing ${sharedEnvPath}. ${hint}`); + } + + const raw = fs.readFileSync(sharedEnvPath, 'utf8'); + return parseEnv(raw); +} + +function isMissingValue(value) { + return value === undefined || value === null || String(value).trim() === ''; +} + +function findMissingRequired(sharedValues) { + const errors = []; + + for (const target of targets) { + const missing = target.required.filter((key) => isMissingValue(sharedValues.get(key))); + if (missing.length > 0) { + errors.push({ target: target.name, missing }); + } + } + + return errors; +} + +function writeTargetEnvFiles(sharedValues) { + for (const target of targets) { + const bodyLines = target.keys.map((key) => `${key}=${sharedValues.get(key) ?? ''}`); + const output = `${OUTPUT_HEADER}${bodyLines.join('\n')}\n`; + fs.writeFileSync(target.outputPath, output, 'utf8'); + console.log(`Updated ${path.relative(repoRoot, target.outputPath)}`); + } +} + +function check(sharedValues) { + const errors = findMissingRequired(sharedValues); + if (errors.length === 0) { + console.log('All required env vars are present in env/.env.shared'); + return; + } + + console.error('Missing required env vars in env/.env.shared:'); + for (const error of errors) { + console.error(`- ${error.target}: ${error.missing.join(', ')}`); + } + process.exitCode = 1; +} + +function sync(sharedValues) { + const errors = findMissingRequired(sharedValues); + if (errors.length > 0) { + console.error('Cannot sync because required vars are missing in env/.env.shared:'); + for (const error of errors) { + console.error(`- ${error.target}: ${error.missing.join(', ')}`); + } + process.exit(1); + } + + writeTargetEnvFiles(sharedValues); +} + +function main() { + const mode = process.argv[2] || 'sync'; + if (!['sync', 'check'].includes(mode)) { + console.error('Usage: node scripts/env-sync.mjs [sync|check]'); + process.exit(1); + } + + const sharedValues = loadSharedValues(); + + if (mode === 'check') { + check(sharedValues); + return; + } + + sync(sharedValues); +} + +main();