Skip to content
Draft
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
9 changes: 6 additions & 3 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
FROM node:24-slim AS base
ENV CI=true

RUN apt-get update && apt-get install -y --no-install-recommends bash curl && \
RUN apt-get update && apt-get install -y --no-install-recommends bash curl python3 make g++ && \
rm -rf /var/lib/apt/lists/*
RUN npm install -g corepack && corepack enable

# Yarn global cache persisted across builds via a BuildKit cache mount (both installs below)
ENV YARN_ENABLE_GLOBAL_CACHE=1

WORKDIR /boxlite/apps

FROM base AS deps
COPY apps/package.json apps/yarn.lock apps/.yarnrc.yml ./
RUN yarn install --immutable
RUN --mount=type=cache,target=/root/.yarn/berry/cache yarn install --immutable

FROM base AS runtime-deps
ENV NODE_ENV=production
COPY apps/package.json apps/yarn.lock apps/.yarnrc.yml ./
RUN yarn workspaces focus --all --production
RUN --mount=type=cache,target=/root/.yarn/berry/cache yarn workspaces focus --all --production

FROM deps AS build
COPY apps/nx.json apps/tsconfig.base.json apps/NOTICE ./
Expand Down
154 changes: 147 additions & 7 deletions apps/infra/scripts/sst-with-cloudflare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* `run()` exists — so its credentials can't be `sst.Secret` like the app
* secrets. They live in AWS SSM Parameter Store (SecureString) instead, keyed
* per stage, and this wrapper fetches + exports them just before invoking sst.
* App secrets are NOT handled here; sst resolves those from its own store.
* App secrets are normally resolved by SST from its own store. For self-hosted
* deploys, set BOXLITE_ENV_SOURCE=ssm to sync /boxlite/<stage>/env/* into this
* app's .env and load the subset that SST models as sst.Secret.
*
* Wired into the dev/deploy/remove npm scripts, plus a passthrough:
* npm run deploy -- --stage dev → node sst-with-cloudflare.mjs deploy --stage dev
Expand All @@ -18,9 +20,9 @@
* same credentials fetch the Cloudflare token from SSM — nothing extra to share.
*
* Seed the parameters once per stage:
* aws ssm put-parameter --region ap-southeast-1 --type SecureString \
* aws ssm put-parameter --region <stage-region> --type SecureString \
* --name /boxlite/<stage>/cloudflare-api-token --value "<token>"
* aws ssm put-parameter --region ap-southeast-1 --type SecureString \
* aws ssm put-parameter --region <stage-region> --type SecureString \
* --name /boxlite/<stage>/cloudflare-account-id --value "<account-id>"
*
* A credential already in the environment is used as-is (works offline / before
Expand All @@ -30,15 +32,35 @@
*/

import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'

const REGION = process.env.AWS_REGION || 'ap-southeast-1'
const DEFAULT_REGION = 'ap-southeast-1'
const STAGE_REGIONS = {
dev: 'ap-southeast-1',
prod: 'us-west-2',
production: 'us-west-2',
}

// SSM param consulted only when the matching env var is unset.
const CREDS = [
{ env: 'CLOUDFLARE_API_TOKEN', param: 'cloudflare-api-token' },
{ env: 'CLOUDFLARE_DEFAULT_ACCOUNT_ID', param: 'cloudflare-account-id' },
]

const CONFIG_COMMANDS = new Set(['deploy', 'dev', 'diff', 'remove'])
const DEFAULT_PRODUCT_ENV_PREFIX_TEMPLATE = '/boxlite/{stage}/env'
const DEFAULT_SST_SECRET_KEYS = [
'OIDC_CLIENT_ID',
'OIDC_MANAGEMENT_API_CLIENT_ID',
'OIDC_MANAGEMENT_API_CLIENT_SECRET',
'POSTHOG_API_KEY',
'SVIX_AUTH_TOKEN',
'SSH_PRIVATE_KEY_B64',
'SSH_HOST_KEY_B64',
]

const sstArgs = process.argv.slice(2)
if (sstArgs.length === 0) {
console.error('sst-with-cloudflare: expected an sst subcommand (e.g. "deploy --stage dev")')
Expand All @@ -56,12 +78,20 @@ function resolveStage(args) {
return process.env.SST_STAGE || 'dev'
}

function runAws(args, options = {}) {
return execFileSync('aws', args, {
encoding: 'utf8',
env: { ...process.env, AWS_REGION: REGION },
maxBuffer: 10 * 1024 * 1024,
...options,
})
}

function fetchFromSsm(name) {
try {
const out = execFileSync(
'aws',
const out = runAws(
['ssm', 'get-parameter', '--region', REGION, '--name', name, '--with-decryption', '--query', 'Parameter.Value', '--output', 'text'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
{ stdio: ['ignore', 'pipe', 'pipe'] },
).trim()
return out && out !== 'None' ? out : null
} catch (err) {
Expand All @@ -70,7 +100,115 @@ function fetchFromSsm(name) {
}
}

function dotenvLine(key, value) {
return `${key}=${JSON.stringify(value)}`
}

function writeAtomically(file, body) {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp-${process.pid}`
writeFileSync(tmp, body, { mode: 0o600 })
renameSync(tmp, file)
chmodSync(file, 0o600)
}

function envPrefixForStage() {
const explicit = process.env.BOXLITE_ENV_SSM_PREFIX || process.env.OPS_PRODUCT_ENV_SSM_PREFIX
const template =
process.env.BOXLITE_ENV_SSM_PREFIX_TEMPLATE ||
process.env.OPS_PRODUCT_ENV_SSM_PREFIX_TEMPLATE ||
DEFAULT_PRODUCT_ENV_PREFIX_TEMPLATE
const source = explicit || template
return source.replaceAll('{stage}', stage)
}

function sstSecretKeys() {
return new Set(
(process.env.BOXLITE_SST_SECRET_KEYS || process.env.OPS_PRODUCT_SST_SECRET_KEYS || DEFAULT_SST_SECRET_KEYS.join(','))
.split(',')
.map((key) => key.trim())
.filter(Boolean),
)
}

function syncProductEnvFromSsm() {
const source = process.env.BOXLITE_ENV_SOURCE || process.env.SST_ENV_SOURCE || 'local'
if (source !== 'ssm' || !CONFIG_COMMANDS.has(sstArgs[0])) return

const prefix = envPrefixForStage()
const outFile = process.env.BOXLITE_ENV_FILE || process.env.OPS_PRODUCT_ENV_FILE || join(process.cwd(), '.env')
let params
try {
const stdout = runAws([
'ssm',
'get-parameters-by-path',
'--region',
REGION,
'--path',
prefix,
'--recursive',
'--with-decryption',
'--query',
'Parameters[].{Name:Name,Value:Value,Version:Version}',
'--output',
'json',
])
params = JSON.parse(stdout || '[]')
} catch (err) {
const detail = err.stderr ? String(err.stderr).trim() : err.message
console.error(`sst-env-sync: failed to read ${prefix} from SSM (${REGION}): ${detail}`)
process.exit(1)
}

const records = params
.map((param) => ({
key: String(param.Name || '').split('/').filter(Boolean).pop(),
value: String(param.Value ?? ''),
version: Number(param.Version || 0),
}))
.filter((param) => param.key)
.sort((a, b) => a.key.localeCompare(b.key))

if (records.length === 0) {
console.error(`sst-env-sync: no parameters found under ${prefix} in ${REGION}; seed SSM before running SST with BOXLITE_ENV_SOURCE=ssm`)
process.exit(1)
}

writeAtomically(outFile, `${records.map((param) => dotenvLine(param.key, param.value)).join('\n')}\n`)

const secretKeys = sstSecretKeys()
const secretLines = records
.filter((param) => secretKeys.has(param.key) && param.value)
.map((param) => dotenvLine(param.key, param.value))

if (secretLines.length) {
const tempDir = mkdtempSync(join(tmpdir(), 'boxlite-sst-secrets-'))
const secretFile = join(tempDir, '.sst-secrets.env')
try {
writeFileSync(secretFile, `${secretLines.join('\n')}\n`, { mode: 0o600 })
console.log(`sst-env-sync: wrote ${outFile} (${records.length} key(s) from ${prefix}); loading ${secretLines.length} SST secret(s)`)
execFileSync('sst', ['secret', 'load', secretFile, '--stage', stage], { stdio: 'inherit', env: process.env })
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
} else {
console.log(`sst-env-sync: wrote ${outFile} (${records.length} key(s) from ${prefix}); no SST secrets matched`)
}

console.log(
`::ops-audit::${JSON.stringify({
phase: 'env-sync',
stage,
region: REGION,
prefix,
keyVersions: records.map((param) => ({ key: param.key, version: param.version })),
sstSecretKeys: records.filter((param) => secretKeys.has(param.key) && param.value).map((param) => param.key),
})}`,
)
}

const stage = resolveStage(sstArgs)
const REGION = process.env.BOXLITE_AWS_REGION || process.env.AWS_REGION || STAGE_REGIONS[stage] || DEFAULT_REGION

for (const { env, param } of CREDS) {
if (process.env[env]) continue // already provided — don't touch
Expand All @@ -86,6 +224,8 @@ for (const { env, param } of CREDS) {
}
}

syncProductEnvFromSsm()

// node_modules/.bin is on PATH because this runs via `npm run`, so `sst` resolves.
const result = spawnSync('sst', sstArgs, { stdio: 'inherit', env: process.env })
if (result.error) {
Expand Down
Loading
Loading