feat(infra): script de provisionamento de workspace — C9

pnpm workspace:provision --id <id> [--name <nome>] [--with-seed]
Cria banco sar_workspace_{id}, habilita extensões, aplica todas as
migrations e opcionalmente popula dados demo. Sem master DB necessário
— JwtAuthGuard resolve a URL pela convenção de nome (ADR 0006).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 01:06:43 +00:00
parent da2f1020d1
commit e3587e680a
2 changed files with 168 additions and 0 deletions

View File

@@ -17,6 +17,7 @@
"e2e": "nx run-many -t e2e", "e2e": "nx run-many -t e2e",
"dev:api": "nx run api:serve", "dev:api": "nx run api:serve",
"dev:web": "nx run web:serve", "dev:web": "nx run web:serve",
"workspace:provision": "tsx scripts/provision-workspace.ts",
"dev:up": "docker compose -f docker-compose.dev.yml up -d", "dev:up": "docker compose -f docker-compose.dev.yml up -d",
"dev:down": "docker compose -f docker-compose.dev.yml down", "dev:down": "docker compose -f docker-compose.dev.yml down",
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f", "dev:logs": "docker compose -f docker-compose.dev.yml logs -f",

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env tsx
/**
* SAR — Provisionamento de workspace (C9)
*
* Uso:
* pnpm workspace:provision --id <workspace-id> [--name <nome>] [--with-seed]
*
* O que faz:
* 1. Cria o banco sar_workspace_<id> (ignora se já existe)
* 2. Habilita pgcrypto + uuid-ossp
* 3. Roda `prisma migrate deploy` no novo banco
* 4. Se --with-seed: popula dados demo (mesmos do seed de dev)
*
* Variáveis de ambiente (todas opcionais — padrões para dev local):
* PG_HOST (default: localhost)
* PG_PORT (default: 5432)
* PG_USER (default: sar)
* PG_PASSWORD (default: sar_dev_password)
* PG_ADMIN_DB (default: postgres)
*
* Convenção BD-por-workspace (ADR 0006):
* O JwtAuthGuard constrói a URL como sar_workspace_{workspaceId} automaticamente.
* Nenhuma entrada em master DB é necessária para o MVP.
*/
import { execSync } from 'child_process';
import { resolve } from 'path';
import pg from 'pg';
// ─── CLI args ─────────────────────────────────────────────────────────────────
function arg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const workspaceId = arg('--id');
const workspaceName = arg('--name') ?? workspaceId;
const withSeed = process.argv.includes('--with-seed');
if (!workspaceId) {
console.error('Uso: pnpm workspace:provision --id <workspace-id> [--name <nome>] [--with-seed]');
console.error('Exemplo: pnpm workspace:provision --id acme-001 --name "Acme Distribuidora"');
process.exit(1);
}
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(workspaceId)) {
console.error('Erro: --id deve conter apenas letras minúsculas, números e hífens (3-64 chars).');
process.exit(1);
}
// ─── Config ───────────────────────────────────────────────────────────────────
const PG_HOST = process.env['PG_HOST'] ?? 'localhost';
const PG_PORT = parseInt(process.env['PG_PORT'] ?? '5432', 10);
const PG_USER = process.env['PG_USER'] ?? 'sar';
const PG_PASSWORD = process.env['PG_PASSWORD'] ?? 'sar_dev_password';
const PG_ADMIN_DB = process.env['PG_ADMIN_DB'] ?? 'postgres';
const DB_NAME = `sar_workspace_${workspaceId}`;
const DB_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${DB_NAME}`;
const MONOREPO_ROOT = resolve(import.meta.dirname, '..');
const API_DIR = resolve(MONOREPO_ROOT, 'apps/api');
// ─── Steps ────────────────────────────────────────────────────────────────────
async function createDatabase(): Promise<void> {
const pool = new pg.Pool({
host: PG_HOST,
port: PG_PORT,
user: PG_USER,
password: PG_PASSWORD,
database: PG_ADMIN_DB,
});
try {
await pool.query(`CREATE DATABASE "${DB_NAME}" OWNER ${PG_USER}`);
console.log(` ✓ Banco criado: ${DB_NAME}`);
} catch (e: unknown) {
if ((e as { code?: string }).code === '42P04') {
console.log(` ~ Banco já existe: ${DB_NAME} — pulando criação`);
} else {
throw e;
}
} finally {
await pool.end();
}
// Extensões no novo banco
const wsPool = new pg.Pool({
host: PG_HOST,
port: PG_PORT,
user: PG_USER,
password: PG_PASSWORD,
database: DB_NAME,
});
try {
await wsPool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
await wsPool.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
console.log(` ✓ Extensões habilitadas (pgcrypto, uuid-ossp)`);
} finally {
await wsPool.end();
}
}
function runMigrations(): void {
execSync(`pnpm exec prisma migrate deploy`, {
cwd: API_DIR,
env: { ...process.env, DATABASE_URL: DB_URL },
stdio: 'inherit',
});
console.log(` ✓ Migrations aplicadas`);
}
function runSeed(): void {
execSync(`pnpm exec prisma db seed`, {
cwd: API_DIR,
env: { ...process.env, DATABASE_URL: DB_URL },
stdio: 'inherit',
});
console.log(` ✓ Seed de demo executado`);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log(`\nSAR — Provisionamento de Workspace`);
console.log(`────────────────────────────────────`);
console.log(`ID: ${workspaceId}`);
console.log(`Nome: ${workspaceName}`);
console.log(`Banco: ${DB_NAME}`);
console.log(`Seed: ${withSeed ? 'sim (--with-seed)' : 'não'}`);
console.log('');
console.log(`[1/3] Criando banco de dados...`);
await createDatabase();
console.log(`[2/3] Aplicando migrations...`);
runMigrations();
if (withSeed) {
console.log(`[3/3] Populando dados demo...`);
runSeed();
} else {
console.log(`[3/3] Seed pulado (use --with-seed para dados demo)`);
}
console.log('');
console.log(`════════════════════════════════════`);
console.log(`✓ Workspace provisionado com sucesso`);
console.log(`════════════════════════════════════`);
console.log('');
console.log(`Próximos passos:`);
console.log(` 1. Emita um JWT com workspace_id = "${workspaceId}"`);
console.log(` usando MASTER_LOGIN_JWT_SECRET do ambiente alvo`);
console.log(` 2. O JwtAuthGuard conecta automaticamente ao banco`);
console.log(` via convenção sar_workspace_{id} (ADR 0006)`);
console.log(` 3. Em produção, defina DATABASE_URL no ambiente`);
console.log(` se o banco não seguir a convenção de nome padrão`);
console.log('');
}
main().catch((e: unknown) => {
console.error('\nErro durante provisionamento:', e);
process.exit(1);
});