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:
Danil Suhomlinov
2026-06-08 17:42:45 +03:00
commit 33800292aa
186 changed files with 30437 additions and 0 deletions
+296
View File
@@ -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;
});