chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)
- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start - apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api - docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web) - .dockerignore / .gitignore / .gitattributes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, ChildProcess } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
type CommandEnv = NodeJS.ProcessEnv;
|
||||
|
||||
function loadEnvFile(filePath: string) {
|
||||
if (!existsSync(filePath)) return;
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalEnv() {
|
||||
loadEnvFile(resolve(process.cwd(), '.env'));
|
||||
loadEnvFile(resolve(process.cwd(), '..', '..', '.env'));
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
'_',
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function databaseNameFromUrl(url: string) {
|
||||
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function databaseUrl(baseUrl: string, dbName: string) {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `/${dbName}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function adminUrl(baseUrl: string) {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = '/postgres';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function assertTestDatabaseName(dbName: string) {
|
||||
if (!/^hrm_medpark_test_[A-Za-z0-9_]+$/.test(dbName)) {
|
||||
throw new Error(`Refusing unsafe database name "${dbName}". Expected hrm_medpark_test_*.`);
|
||||
}
|
||||
}
|
||||
|
||||
function quotedIdentifier(dbName: string) {
|
||||
assertTestDatabaseName(dbName);
|
||||
return `"${dbName.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function pnpmCommand() {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
||||
}
|
||||
|
||||
function cleanEnv(env: CommandEnv) {
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined || key.startsWith('=')) continue;
|
||||
cleaned[key] = value;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function createDatabase(baseUrl: string, dbName: string) {
|
||||
assertTestDatabaseName(dbName);
|
||||
const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } });
|
||||
try {
|
||||
await admin.$executeRawUnsafe(`CREATE DATABASE ${quotedIdentifier(dbName)}`);
|
||||
} finally {
|
||||
await admin.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(baseUrl: string, dbName: string) {
|
||||
assertTestDatabaseName(dbName);
|
||||
const admin = new PrismaClient({ datasources: { db: { url: adminUrl(baseUrl) } } });
|
||||
try {
|
||||
await admin.$executeRawUnsafe(
|
||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()',
|
||||
dbName,
|
||||
);
|
||||
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${quotedIdentifier(dbName)}`);
|
||||
} finally {
|
||||
await admin.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(label: string, args: string[], env: CommandEnv) {
|
||||
console.log(`\n> ${label}`);
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(pnpmCommand(), args, {
|
||||
cwd: process.cwd(),
|
||||
env: cleanEnv(env),
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`${label} failed with exit code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findFreePort() {
|
||||
return new Promise<number>((resolvePromise, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
reject(new Error('Unable to allocate a free API port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolvePromise(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApi(baseUrl: string, child: ChildProcess) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 60_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Temporary API exited before becoming ready (exit code ${child.exitCode})`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/dev-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'readiness', role: 'hr_admin' }),
|
||||
});
|
||||
if (response.status === 200 || response.status === 201) return;
|
||||
} catch {
|
||||
// API is still booting.
|
||||
}
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
|
||||
}
|
||||
throw new Error(`Temporary API did not become ready at ${baseUrl}`);
|
||||
}
|
||||
|
||||
async function startTemporaryApi(dbUrl: string, dbName: string) {
|
||||
const port = Number(process.env.TEST_API_PORT ?? (await findFreePort()));
|
||||
const baseUrl = `http://127.0.0.1:${port}/api/v1`;
|
||||
const bucket = `hrm-docs-test-${dbName.replace(/_/g, '-')}`.slice(0, 63);
|
||||
const env: CommandEnv = {
|
||||
...process.env,
|
||||
DATABASE_URL: dbUrl,
|
||||
PORT: String(port),
|
||||
NODE_ENV: 'test',
|
||||
ALLOW_DEV_LOGIN: 'true',
|
||||
MINIO_BUCKET: bucket,
|
||||
};
|
||||
|
||||
console.log(`\n> Starting temporary API on ${baseUrl}`);
|
||||
const child = spawn(pnpmCommand(), ['exec', 'nest', 'start'], {
|
||||
cwd: process.cwd(),
|
||||
env: cleanEnv(env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(`[api] ${chunk.toString()}`));
|
||||
child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(`[api] ${chunk.toString()}`));
|
||||
await waitForApi(baseUrl, child);
|
||||
return { child, baseUrl };
|
||||
}
|
||||
|
||||
async function stopTemporaryApi(child: ChildProcess | null) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
const waitForExit = async () => {
|
||||
if (child.exitCode !== null) return;
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
const timeout = setTimeout(() => resolvePromise(), 10_000);
|
||||
const done = () => {
|
||||
clearTimeout(timeout);
|
||||
resolvePromise();
|
||||
};
|
||||
child.once('exit', done);
|
||||
child.once('close', done);
|
||||
});
|
||||
};
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
killer.on('exit', () => resolvePromise());
|
||||
killer.on('error', () => resolvePromise());
|
||||
});
|
||||
await waitForExit();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.unref();
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
await waitForExit();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
loadLocalEnv();
|
||||
const baseUrl = process.env.DATABASE_URL;
|
||||
if (!baseUrl) throw new Error('DATABASE_URL is required. Put it in apps/api/.env or export it before running testdb:run.');
|
||||
|
||||
const dbName = `hrm_medpark_test_${timestamp()}`;
|
||||
const dbUrl = databaseUrl(baseUrl, dbName);
|
||||
const commandEnv: CommandEnv = { ...process.env, DATABASE_URL: dbUrl };
|
||||
let api: Awaited<ReturnType<typeof startTemporaryApi>> | null = null;
|
||||
|
||||
console.log(`Creating temporary database ${dbName}...`);
|
||||
await createDatabase(baseUrl, dbName);
|
||||
|
||||
try {
|
||||
await runCommand('prisma migrate deploy', ['exec', 'prisma', 'migrate', 'deploy'], commandEnv);
|
||||
await runCommand('seed test data', ['exec', 'ts-node', 'scripts/seed-test-data.ts'], commandEnv);
|
||||
|
||||
api = await startTemporaryApi(dbUrl, dbName);
|
||||
await runCommand('verify functionality', ['exec', 'ts-node', 'scripts/verify-functionality.ts'], {
|
||||
...commandEnv,
|
||||
API_BASE_URL: api.baseUrl,
|
||||
});
|
||||
|
||||
console.log('\nTemporary database is ready for manual checks.');
|
||||
console.log(`DATABASE_URL=${dbUrl}`);
|
||||
console.log('\nRun API manually from the repo root with:');
|
||||
console.log(` $env:DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api dev`);
|
||||
console.log('\nDrop it later with:');
|
||||
console.log(` $env:TEST_DATABASE_URL='${dbUrl}'; pnpm.cmd --filter api testdb:drop`);
|
||||
} catch (error) {
|
||||
console.error('\nTest database run failed. The database was left in place for inspection:');
|
||||
console.error(`DATABASE_URL=${dbUrl}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTemporaryApi(api?.child ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
function targetDatabaseFromArgs(baseUrl: string) {
|
||||
const arg = process.argv.slice(3).find((value) => value !== '--') ?? process.env.TEST_DATABASE_URL ?? process.env.TEST_DATABASE_NAME;
|
||||
if (!arg) {
|
||||
throw new Error('Provide a test DB name or URL: pnpm.cmd --filter api testdb:drop -- hrm_medpark_test_YYYYMMDD_HHMMSS');
|
||||
}
|
||||
if (/^postgres(?:ql)?:\/\//.test(arg)) {
|
||||
return { dbName: databaseNameFromUrl(arg), baseUrl: arg };
|
||||
}
|
||||
return { dbName: arg, baseUrl };
|
||||
}
|
||||
|
||||
async function drop() {
|
||||
loadLocalEnv();
|
||||
const baseUrl = process.env.DATABASE_URL;
|
||||
if (!baseUrl) throw new Error('DATABASE_URL is required for admin connection');
|
||||
const target = targetDatabaseFromArgs(baseUrl);
|
||||
assertTestDatabaseName(target.dbName);
|
||||
console.log(`Dropping temporary database ${target.dbName}...`);
|
||||
await dropDatabase(target.baseUrl, target.dbName);
|
||||
console.log(`Dropped ${target.dbName}.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2] ?? 'run';
|
||||
if (command === 'run') {
|
||||
await run();
|
||||
return;
|
||||
}
|
||||
if (command === 'drop') {
|
||||
await drop();
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown command "${command}". Use "run" or "drop".`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user