From ca8a1112f05884d29ced1974d280cce6bdf7988d Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:39:15 -0300 Subject: [PATCH] Corrige quatro defeitos de runtime do worker e dos scripts operacionais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os quatro apareceram ao exercitar o layout de runtime da imagem — apenas node_modules de produção, src, scripts e migrations — e nenhum aparecia na suíte de testes nem no build. Os dois primeiros formavam uma armadilha mútua. 1. O CLI do `tsx` cria processo filho (PIDs medidos: 20840 contra 21096). O SIGTERM do `docker stop` pararia no pai, o worker nunca rodaria o handler, o lock esperaria o TTL de 120s e o `stop_grace_period` só atrasaria um SIGKILL. Passa a ser `node --import tsx`, mesmo processo. 2. Top-level await nos scripts falha com `--import tsx`, porque sem `"type": "module"` o esbuild gera CJS. `migrate`, `worker`, `seed-staging`, `flag`, `cleanup` e `check-bucket` não executariam: a migração não rodaria e o worker não subiria. `runScript`/`runWithDb` resolvem sem mudar o module type do projeto. 3. `import "server-only"` em `worker-loop.ts`: o pacote lança fora do bundler do Next, e o worker morria no start. 4. `@react-pdf/renderer` carregado no import de `process-job.ts` falha com ERR_PACKAGE_PATH_NOT_EXPORTED ao resolver a hifenização fora do bundler, derrubando worker e cleanup. O PDF sai sob demanda no download, então o import passou a ser tardio, dentro do ramo que o usa. Além disso: - O loop do worker saiu do script para `worker-loop.ts`, testável sem subir processo. O script cuida só de sinais, cadência e bootstrap. - Desligamento gracioso espera o tick corrente para o `finally` liberar o lock. Um job que ainda não começou fica `queued`: nada pela metade. - Heartbeat por tick, lido pelo healthcheck do contêiner. Processo vivo mas travado passa em qualquer verificação de PID. - O worker recusa iniciar com migration pendente, e relê o contexto do dono a cada job: papel e feature flag podem ter mudado desde o enfileiramento. - `verify-config`, `check-bucket`, `seed-staging` e `smoke-staging` novos. O smoke tem fase autenticada que só roda com credencial fornecida pela operação e, sem ela, reporta PENDENTE em vez de aprovado. Co-Authored-By: Claude Opus 5 --- package.json | 25 +- scripts/_bootstrap.ts | 67 +++++ scripts/check-bucket.ts | 16 ++ scripts/cleanup-temp.ts | 17 +- scripts/migrate.ts | 36 ++- scripts/seed-dev.ts | 22 +- scripts/seed-staging.ts | 134 +++++++++ scripts/set-flag.ts | 53 ++-- scripts/smoke-staging.ts | 259 ++++++++++++++++++ scripts/verify-config.ts | 18 ++ scripts/worker.ts | 133 +++++---- src/server/spreadsheet-adapter/process-job.ts | 5 +- src/server/spreadsheet-adapter/worker-loop.ts | 176 ++++++++++++ 13 files changed, 835 insertions(+), 126 deletions(-) create mode 100644 scripts/_bootstrap.ts create mode 100644 scripts/check-bucket.ts create mode 100644 scripts/seed-staging.ts create mode 100644 scripts/smoke-staging.ts create mode 100644 scripts/verify-config.ts create mode 100644 src/server/spreadsheet-adapter/worker-loop.ts diff --git a/package.json b/package.json index 371ffcf..f1ca4e4 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,30 @@ "name": "adaptador-planilhas", "version": "0.1.0", "private": true, + "engines": { + "node": ">=22.12 <25" + }, + "packageManager": "npm@11.6.2", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", + "start:standalone": "node .next/standalone/server.js", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "migrate": "tsx scripts/migrate.ts", - "seed": "tsx scripts/seed-dev.ts", - "worker": "tsx scripts/worker.ts", - "cleanup": "tsx scripts/cleanup-temp.ts", - "flag": "tsx scripts/set-flag.ts" + "test:services": "vitest run --config vitest.services.config.ts", + "migrate": "node --import=tsx scripts/migrate.ts", + "migrate:check": "node --import=tsx scripts/migrate.ts --dry-run", + "seed": "node --import=tsx scripts/seed-dev.ts", + "seed:staging": "node --import=tsx scripts/seed-staging.ts", + "worker": "node --import=tsx scripts/worker.ts", + "cleanup": "node --import=tsx scripts/cleanup-temp.ts", + "flag": "node --import=tsx scripts/set-flag.ts", + "verify:config": "node --import=tsx scripts/verify-config.ts", + "check:bucket": "node --import=tsx scripts/check-bucket.ts", + "smoke:staging": "node --import=tsx scripts/smoke-staging.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -31,7 +42,8 @@ "postgres": "^3.4.7", "react": "^19.2.0", "react-dom": "^19.2.0", - "server-only": "^0.0.1" + "server-only": "^0.0.1", + "tsx": "^4.20.6" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", @@ -46,7 +58,6 @@ "eslint-config-next": "^16.0.0", "jsdom": "^27.0.0", "tailwindcss": "^4.1.14", - "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^3.2.4" } diff --git a/scripts/_bootstrap.ts b/scripts/_bootstrap.ts new file mode 100644 index 0000000..04c5880 --- /dev/null +++ b/scripts/_bootstrap.ts @@ -0,0 +1,67 @@ +import path from "node:path"; +import { adapterEnv, identityShimEnabled, isDeployedEnv, requireDatabaseUrl } from "../src/config/env"; +import { exitOnInvalidConfig } from "../src/config/fail-fast"; +import { createFileDb } from "../src/infra/db/pglite"; +import { createPostgresDb } from "../src/infra/db/postgres"; +import type { Db } from "../src/infra/db/types"; + +/** + * Banco para os scripts operacionais. Em staging e produção `DATABASE_URL` é + * obrigatória: cair em PGlite com um segredo ausente faria uma migração + * silenciosa num arquivo local parecer sucesso. + */ +export async function openOperationalDb(): Promise { + const databaseUrl = requireDatabaseUrl(); + if (databaseUrl) return createPostgresDb(databaseUrl); + if (isDeployedEnv()) { + throw new Error("DATABASE_URL é obrigatória fora de development."); + } + const dataDir = path.join(process.cwd(), ".data", "pglite"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(dataDir, { recursive: true }); + return createFileDb(dataDir); +} + +/** Recusa configuração inválida antes de tocar em qualquer recurso externo. */ +export function bootstrapScript(name: string): void { + exitOnInvalidConfig(process.env); + console.log(`[${name}] ambiente=${adapterEnv()} shim_identidade=${identityShimEnabled() ? "sim" : "nao"}`); +} + +export function describeTarget(): string { + return process.env.DATABASE_URL ? "postgres" : "pglite local"; +} + +/** + * Executa o corpo do script. + * + * Existe para que nenhum script use top-level await: `tsx` compila `.ts` como + * CommonJS quando o `package.json` não declara `"type": "module"`, e aí top-level + * await é erro de transformação. Isso impedia rodar os scripts com + * `node --import tsx`, que é a forma que entrega SIGTERM ao nosso processo — o + * CLI do `tsx` cria um processo filho e o sinal não chega ao handler. + */ +export function runScript(name: string, body: () => Promise): void { + body().then( + () => { + process.exitCode ??= 0; + }, + (error: unknown) => { + const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro"; + console.error(`[${name}] ${label}`); + process.exit(1); + }, + ); +} + +/** Abre o banco, executa e fecha, mesmo em caso de falha. */ +export function runWithDb(name: string, body: (db: Db) => Promise): void { + runScript(name, async () => { + const db = await openOperationalDb(); + try { + await body(db); + } finally { + await db.close().catch(() => undefined); + } + }); +} diff --git a/scripts/check-bucket.ts b/scripts/check-bucket.ts new file mode 100644 index 0000000..0354d5d --- /dev/null +++ b/scripts/check-bucket.ts @@ -0,0 +1,16 @@ +import { assertPrivateBucket } from "../src/infra/storage/supabase"; +import { runScript } from "./_bootstrap"; + +/** Confere que o bucket existe e é privado. Não cria bucket. */ +const url = process.env.NEXT_PUBLIC_SUPABASE_URL; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY; +const bucket = process.env.SUPABASE_STORAGE_BUCKET; +if (!url || !key || !bucket) { + console.error("Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET."); + process.exit(1); +} + +runScript("check-bucket", async () => { + await assertPrivateBucket(url, key, bucket); + console.log(`bucket "${bucket}" existe e é privado`); +}); diff --git a/scripts/cleanup-temp.ts b/scripts/cleanup-temp.ts index 1ac1acf..28769d0 100644 --- a/scripts/cleanup-temp.ts +++ b/scripts/cleanup-temp.ts @@ -1,12 +1,11 @@ -import path from "node:path"; -import { createFileDb } from "../src/infra/db/pglite"; -import { createPostgresDb } from "../src/infra/db/postgres"; -import { MemoryCoordinator } from "../src/infra/redis/memory"; +import { createCoordinator } from "../src/infra/redis"; import { createStorage } from "../src/infra/storage"; import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job"; +import { bootstrapScript, runWithDb } from "./_bootstrap"; -const databaseUrl = process.env.DATABASE_URL; -const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() }); -console.log(`temporários expirados: ${removed}`); -await db.close(); +bootstrapScript("cleanup-temp"); + +runWithDb("cleanup-temp", async (db) => { + const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() }); + console.log(`temporários expirados: ${removed}`); +}); diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 5d8e23a..3f28c7a 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,10 +1,28 @@ -import { createFileDb } from "../src/infra/db/pglite"; -import { createPostgresDb } from "../src/infra/db/postgres"; -import { migrate } from "../src/infra/db/migrate"; -import path from "node:path"; +import { identityShimEnabled } from "../src/config/env"; +import { migrate, pendingMigrations } from "../src/infra/db/migrate"; +import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap"; -const databaseUrl = process.env.DATABASE_URL; -const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -const ran = await migrate(db); -console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente"); -await db.close(); +/** + * Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline + * antes do passo manual de migração, para que ninguém descubra o que vai rodar + * só depois de ter rodado. + */ +const dryRun = process.argv.includes("--dry-run"); + +bootstrapScript("migrate"); + +runWithDb("migrate", async (db) => { + const pending = await pendingMigrations(db); + console.log(`alvo: ${describeTarget()}`); + console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`); + if (pending.length === 0) { + console.log("migrations: nada pendente"); + return; + } + if (dryRun) { + console.log(`migrations pendentes (dry-run, nada aplicado): ${pending.join(", ")}`); + return; + } + const ran = await migrate(db); + console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente"); +}); diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 263081f..f7d869e 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -1,12 +1,16 @@ -import path from "node:path"; -import { createFileDb } from "../src/infra/db/pglite"; -import { createPostgresDb } from "../src/infra/db/postgres"; +import { adapterEnv } from "../src/config/env"; import { migrate } from "../src/infra/db/migrate"; import { ensureDevFixtures } from "../src/dev/fixtures"; +import { bootstrapScript, runWithDb } from "./_bootstrap"; -const databaseUrl = process.env.DATABASE_URL; -const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -await migrate(db); -await ensureDevFixtures(db); -console.log("fixtures de desenvolvimento conferidas"); -await db.close(); +bootstrapScript("seed-dev"); +if (adapterEnv() !== "development") { + console.error("seed-dev só roda em development. Para homologação use `npm run seed:staging`."); + process.exit(1); +} + +runWithDb("seed-dev", async (db) => { + await migrate(db); + await ensureDevFixtures(db); + console.log("fixtures de desenvolvimento conferidas"); +}); diff --git a/scripts/seed-staging.ts b/scripts/seed-staging.ts new file mode 100644 index 0000000..6f98e18 --- /dev/null +++ b/scripts/seed-staging.ts @@ -0,0 +1,134 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { adapterEnv, isProductionEnv } from "../src/config/env"; +import { migrate } from "../src/infra/db/migrate"; +import type { Db } from "../src/infra/db/types"; +import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap"; + +/** + * Seed idempotente de homologação. + * + * Não cria conta, senha ou sessão: as identidades vivem no Supabase Auth do + * projeto de homologação e são criadas pela operação no painel do Supabase. + * Este script apenas liga aqueles `user_id` já existentes a workspaces e + * papéis. Rodar duas vezes produz o mesmo estado. + * + * Arquivo: `ADAPTER_STAGING_SEED_FILE` (padrão `config/staging-seed.json`). + * Modelo sem segredo em `config/staging-seed.example.json`. + */ + +type SeedWorkspace = { id: string; name: string; moduleEnabled: boolean; allowNonAdminModelUpload?: boolean }; +type SeedUser = { id: string; displayName: string; isSuperadmin?: boolean }; +type SeedMember = { workspaceId: string; userId: string; role: "owner" | "admin" | "gestor" | "agent" }; +type SeedFile = { workspaces: SeedWorkspace[]; users: SeedUser[]; members: SeedMember[] }; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ROLES = new Set(["owner", "admin", "gestor", "agent"]); + +function assertSeed(seed: SeedFile): void { + const workspaceIds = new Set(); + for (const workspace of seed.workspaces) { + if (!UUID_RE.test(workspace.id)) throw new Error(`workspace.id não é UUID: ${workspace.id}`); + if (!workspace.name?.trim()) throw new Error(`workspace ${workspace.id} sem nome`); + workspaceIds.add(workspace.id.toLowerCase()); + } + const userIds = new Set(); + for (const user of seed.users) { + if (!UUID_RE.test(user.id)) { + throw new Error(`users[].id deve ser o UUID do usuário no Supabase Auth: ${user.id}`); + } + if (!user.displayName?.trim()) throw new Error(`usuário ${user.id} sem displayName`); + userIds.add(user.id.toLowerCase()); + } + for (const member of seed.members) { + if (!ROLES.has(member.role)) throw new Error(`papel inválido: ${member.role}`); + if (!workspaceIds.has(member.workspaceId.toLowerCase())) { + throw new Error(`membro aponta workspace fora do seed: ${member.workspaceId}`); + } + if (!userIds.has(member.userId.toLowerCase())) { + throw new Error(`membro aponta usuário fora do seed: ${member.userId}`); + } + } +} + +async function applySeed(db: Db, seed: SeedFile): Promise { + await db.withService(async (query) => { + for (const workspace of seed.workspaces) { + await query.query( + `insert into public.workspaces (id, name) values ($1, $2) + on conflict (id) do update set name = excluded.name`, + [workspace.id, workspace.name], + ); + await query.query( + `insert into public.workspace_module_flags (workspace_id, module_key, enabled) + values ($1, 'spreadsheet_adapter', $2) + on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`, + [workspace.id, workspace.moduleEnabled], + ); + if (workspace.allowNonAdminModelUpload !== undefined) { + await query.query( + `insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload) + values ($1, $2) + on conflict (workspace_id) do update set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload`, + [workspace.id, workspace.allowNonAdminModelUpload], + ); + } + } + for (const user of seed.users) { + await query.query( + `insert into public.profiles (id, display_name, is_superadmin) values ($1, $2, $3) + on conflict (id) do update set display_name = excluded.display_name, is_superadmin = excluded.is_superadmin`, + [user.id, user.displayName, user.isSuperadmin ?? false], + ); + } + for (const member of seed.members) { + await query.query( + `insert into public.workspace_members (workspace_id, user_id, role) values ($1, $2, $3) + on conflict (workspace_id, user_id) do update set role = excluded.role`, + [member.workspaceId, member.userId, member.role], + ); + } + }); +} + +bootstrapScript("seed-staging"); + +if (isProductionEnv()) { + console.error("seed-staging não roda em produção. As associações de produção são da plataforma Elo Pro."); + process.exit(1); +} +if (adapterEnv() === "development" && process.env.ADAPTER_SEED_FORCE !== "1") { + console.error("Em development use `npm run seed`. Para forçar este seed, defina ADAPTER_SEED_FORCE=1."); + process.exit(1); +} + +const file = process.env.ADAPTER_STAGING_SEED_FILE ?? path.join("config", "staging-seed.json"); + +runScript("seed-staging", async () => { + let seed: SeedFile; + try { + seed = JSON.parse(await readFile(path.resolve(process.cwd(), file), "utf8")) as SeedFile; + } catch (error) { + console.error( + `Não foi possível ler o seed em "${file}". Copie config/staging-seed.example.json e preencha com os UUID do Supabase Auth.`, + ); + console.error(error instanceof Error ? error.name : "erro"); + process.exit(1); + } + seed.workspaces ??= []; + seed.users ??= []; + seed.members ??= []; + assertSeed(seed); + + const db = await openOperationalDb(); + try { + const ran = await migrate(db); + if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`); + await applySeed(db, seed); + console.log( + `seed de homologação aplicado em ${describeTarget()}: ${seed.workspaces.length} workspace(s), ${seed.users.length} identidade(s), ${seed.members.length} associação(ões)`, + ); + } finally { + await db.close().catch(() => undefined); + } +}); diff --git a/scripts/set-flag.ts b/scripts/set-flag.ts index cab44d6..e6733a9 100644 --- a/scripts/set-flag.ts +++ b/scripts/set-flag.ts @@ -1,30 +1,37 @@ -import path from "node:path"; -import { createFileDb } from "../src/infra/db/pglite"; -import { createPostgresDb } from "../src/infra/db/postgres"; +import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap"; const workspaceId = process.argv[2]; -const enabled = process.argv[3] !== "off"; -if (!workspaceId) { +const action = process.argv[3]; +if (!workspaceId || !action || !["on", "off"].includes(action)) { console.error("uso: npm run flag -- on|off"); process.exit(1); } -const databaseUrl = process.env.DATABASE_URL; -const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -await db.withService(async (query) => { - await query.query( - `insert into public.workspace_module_flags (workspace_id, module_key, enabled) - values ($1, 'spreadsheet_adapter', $2) - on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`, - [workspaceId, enabled], - ); - await query.query( - `insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata) - select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean) - from public.workspace_members m - where m.workspace_id = $1 and m.role = 'owner' - limit 1`, - [workspaceId, enabled], +const enabled = action === "on"; + +bootstrapScript("set-flag"); + +runWithDb("set-flag", async (db) => { + const existing = await db.withService((query) => + query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]), ); + if (!existing[0]) { + throw new Error(`workspace ${workspaceId} não existe em ${describeTarget()}. Nada foi alterado.`); + } + await db.withService(async (query) => { + await query.query( + `insert into public.workspace_module_flags (workspace_id, module_key, enabled) + values ($1, 'spreadsheet_adapter', $2) + on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`, + [workspaceId, enabled], + ); + await query.query( + `insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata) + select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean) + from public.workspace_members m + where m.workspace_id = $1 and m.role = 'owner' + limit 1`, + [workspaceId, enabled], + ); + }); + console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"} para "${existing[0].name}" (${workspaceId})`); }); -console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`); -await db.close(); diff --git a/scripts/smoke-staging.ts b/scripts/smoke-staging.ts new file mode 100644 index 0000000..1174253 --- /dev/null +++ b/scripts/smoke-staging.ts @@ -0,0 +1,259 @@ +import { runScript } from "./_bootstrap"; + +/** + * Smoke de homologação contra o ambiente em pé. + * + * Fase 1 (sempre): saúde, recusa sem sessão, cabeçalhos, ausência de dev auth. + * Fase 2 (só com credencial fornecida): login real no Supabase Auth de + * homologação, resolução de workspace e leitura autenticada do módulo. + * + * Nenhuma credencial é inventada aqui. A fase 2 só roda quando a operação + * fornece as variáveis abaixo, com uma conta criada no painel do Supabase: + * + * ADAPTER_SMOKE_URL URL pública, ex. https://hml-ada.elopro.app + * ADAPTER_SMOKE_EMAIL e-mail de uma conta de teste de homologação + * ADAPTER_SMOKE_PASSWORD senha dessa conta + * ADAPTER_SMOKE_WORKSPACE (opcional) UUID do workspace esperado + * + * Sem ADAPTER_SMOKE_EMAIL/PASSWORD a fase 2 é reportada como PENDENTE, nunca + * como aprovada. O envio de planilha, o processamento e o download seguem no + * roteiro manual do runbook: exigem arquivo e a flag do módulo ligada. + */ + +type Check = { name: string; ok: boolean; detail: string; pending?: boolean }; + +const base = (process.env.ADAPTER_SMOKE_URL ?? "").replace(/\/$/, ""); +const email = process.env.ADAPTER_SMOKE_EMAIL; +const password = process.env.ADAPTER_SMOKE_PASSWORD; +const expectedWorkspace = process.env.ADAPTER_SMOKE_WORKSPACE; + +if (!base) { + console.error("Defina ADAPTER_SMOKE_URL com a URL pública do ambiente."); + process.exit(1); +} + +const checks: Check[] = []; + +function record(name: string, ok: boolean, detail: string) { + checks.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`); +} + +function pending(name: string, detail: string) { + checks.push({ name, ok: false, detail, pending: true }); + console.log(`PENDENTE ${name} — ${detail}`); +} + +async function fetchJson(path: string, init?: RequestInit): Promise<{ status: number; body: unknown; response: Response }> { + const response = await fetch(`${base}${path}`, { + redirect: "manual", + ...init, + headers: { accept: "application/json", ...(init?.headers ?? {}) }, + }); + const text = await response.text(); + let body: unknown = text; + try { + body = JSON.parse(text); + } catch { + /* resposta não-JSON entra como texto */ + } + return { status: response.status, body, response }; +} + +/** Junta os cookies de Set-Cookie para a próxima requisição. */ +function collectCookies(response: Response, jar: Map): void { + const raw = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; + for (const line of raw) { + const [pair] = line.split(";"); + const index = pair.indexOf("="); + if (index > 0) jar.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim()); + } +} + +function cookieHeader(jar: Map): string { + return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; "); +} + +runScript("smoke-staging", async () => { + // ---------------------------------------------------------------- fase 1 + console.log(`== fase 1: ambiente em ${base} ==`); + + try { + const live = await fetchJson("/api/health/live"); + const body = live.body as { status?: string }; + record("health/live", live.status === 200 && body?.status === "live", `status ${live.status}`); + } catch (error) { + record("health/live", false, error instanceof Error ? error.message : "falha de rede"); + } + + let environment: string | undefined; + try { + const ready = await fetchJson("/api/health/ready"); + const body = ready.body as { + status?: string; + environment?: string; + checks?: Record; + reasons?: Record; + configIssues?: string[]; + }; + environment = body?.environment; + record( + "health/ready", + ready.status === 200 && body?.status === "ready", + `status ${ready.status} checks=${JSON.stringify(body?.checks ?? {})} reasons=${JSON.stringify(body?.reasons ?? {})}`, + ); + record("ambiente é staging", body?.environment === "staging", `environment=${body?.environment ?? "?"}`); + record( + "sem pendência de configuração", + (body?.configIssues ?? []).length === 0, + (body?.configIssues ?? []).join("; ") || "nenhuma", + ); + } catch (error) { + record("health/ready", false, error instanceof Error ? error.message : "falha de rede"); + } + + try { + const anon = await fetchJson("/api/spreadsheet-adapter/models"); + record("API sem sessão recusa", anon.status === 401, `status ${anon.status}`); + } catch (error) { + record("API sem sessão recusa", false, error instanceof Error ? error.message : "falha de rede"); + } + + try { + const page = await fetch(`${base}/adaptador`, { redirect: "manual" }); + const location = page.headers.get("location") ?? ""; + record( + "/adaptador sem sessão redireciona ao login", + page.status >= 300 && page.status < 400 && location.includes("/login"), + `status ${page.status} location=${location || "(vazio)"}`, + ); + } catch (error) { + record("/adaptador sem sessão redireciona ao login", false, error instanceof Error ? error.message : "falha de rede"); + } + + try { + const response = await fetch(`${base}/api/health/live`, { redirect: "manual" }); + const required = [ + "content-security-policy", + "strict-transport-security", + "x-content-type-options", + "x-frame-options", + "referrer-policy", + "permissions-policy", + ]; + const missing = required.filter((header) => !response.headers.get(header)); + record("cabeçalhos de segurança", missing.length === 0, missing.length ? `faltam: ${missing.join(", ")}` : "todos presentes"); + } catch (error) { + record("cabeçalhos de segurança", false, error instanceof Error ? error.message : "falha de rede"); + } + + // Dev auth não pode existir em homologação pública, em nenhuma circunstância. + try { + const dev = await fetch(`${base}/api/dev/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: "00000000-0000-4000-8000-000000000000", + workspaceId: "00000000-0000-4000-8000-000000000000", + }), + }); + record("dev auth indisponível", dev.status === 404, `status ${dev.status}`); + } catch (error) { + record("dev auth indisponível", false, error instanceof Error ? error.message : "falha de rede"); + } + + // ---------------------------------------------------------------- fase 2 + console.log(""); + console.log("== fase 2: login real no Supabase Auth de homologação =="); + + if (!email || !password) { + pending( + "login real", + "defina ADAPTER_SMOKE_EMAIL e ADAPTER_SMOKE_PASSWORD de uma conta criada no painel do Supabase de homologação", + ); + pending("resolução de workspace autenticada", "depende do login real"); + pending("leitura autenticada do módulo", "depende do login real"); + } else { + const jar = new Map(); + let logged = false; + try { + const login = await fetch(`${base}/api/auth/staging/session`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + collectCookies(login, jar); + logged = login.status === 200 && jar.size > 0; + record("login real", logged, `status ${login.status} cookies=${jar.size}`); + } catch (error) { + record("login real", false, error instanceof Error ? error.message : "falha de rede"); + } + + if (logged) { + try { + const workspaces = await fetchJson("/api/auth/workspace", { headers: { cookie: cookieHeader(jar) } }); + const body = workspaces.body as { memberships?: { workspaceId: string; workspaceName: string; role: string }[] }; + const list = body?.memberships ?? []; + record( + "resolução de workspace autenticada", + workspaces.status === 200 && list.length > 0, + `status ${workspaces.status} associações=${list.length}`, + ); + if (expectedWorkspace) { + record( + "workspace esperado está associado", + list.some((item) => item.workspaceId.toLowerCase() === expectedWorkspace.toLowerCase()), + `esperado=${expectedWorkspace}`, + ); + } + if (list.length > 0) { + const chosen = expectedWorkspace ?? list[0].workspaceId; + const select = await fetch(`${base}/api/auth/workspace`, { + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/json", cookie: cookieHeader(jar) }, + body: JSON.stringify({ workspaceId: chosen }), + }); + collectCookies(select, jar); + record("seleção de workspace aceita", select.status === 200, `status ${select.status}`); + } + } catch (error) { + record("resolução de workspace autenticada", false, error instanceof Error ? error.message : "falha de rede"); + } + + try { + const models = await fetchJson("/api/spreadsheet-adapter/models", { headers: { cookie: cookieHeader(jar) } }); + // 200 com o módulo ligado; 403 FEATURE_DISABLED é resposta correta + // enquanto a feature flag do workspace continua desligada. + const body = models.body as { error?: { code?: string } }; + const aceitavel = models.status === 200 || body?.error?.code === "FEATURE_DISABLED"; + record( + "leitura autenticada do módulo", + aceitavel, + `status ${models.status} code=${body?.error?.code ?? "ok"}`, + ); + } catch (error) { + record("leitura autenticada do módulo", false, error instanceof Error ? error.message : "falha de rede"); + } + } else { + pending("resolução de workspace autenticada", "login não concluiu"); + pending("leitura autenticada do módulo", "login não concluiu"); + } + } + + // ---------------------------------------------------------------- resumo + const failed = checks.filter((check) => !check.ok && !check.pending); + const pendentes = checks.filter((check) => check.pending); + console.log(""); + console.log(`${checks.length - failed.length - pendentes.length}/${checks.length} aprovadas, ${pendentes.length} pendentes, ${failed.length} falhas.`); + console.log(`ambiente reportado: ${environment ?? "desconhecido"}`); + console.log(""); + console.log("Continua fora deste script e obrigatório no runbook:"); + console.log(" - envio de modelo e de fonte pela interface"); + console.log(" - processamento assíncrono com o worker e download XLSX, CSV e PDF"); + console.log(" - troca de workspace e sessão expirada no navegador"); + console.log(" - evento controlado no Sentry, confirmado sem conteúdo de célula"); + console.log(" - ensaio de rollback de imagem"); + if (failed.length > 0) process.exit(1); +}); diff --git a/scripts/verify-config.ts b/scripts/verify-config.ts new file mode 100644 index 0000000..52cef25 --- /dev/null +++ b/scripts/verify-config.ts @@ -0,0 +1,18 @@ +import { adapterEnv, collectConfigIssues, identityShimEnabled, jobRunsInline, stagingAuthEnabled } from "../src/config/env"; + +/** + * Confere a configuração sem tocar em recurso externo. Usado no pipeline antes + * do deploy e dentro do container no primeiro start. + */ +const issues = collectConfigIssues(process.env); +console.log(`ambiente: ${adapterEnv()}`); +console.log(`modo de job: ${jobRunsInline() ? "inline" : "worker"}`); +console.log(`shim de identidade: ${identityShimEnabled() ? "aplicar" : "não aplicar"}`); +console.log(`login próprio de homologação: ${stagingAuthEnabled() ? "ligado" : "desligado"}`); +if (issues.length === 0) { + console.log("configuração aceita"); + process.exit(0); +} +console.error("configuração recusada:"); +for (const issue of issues) console.error(` - ${issue.variable}: ${issue.message}`); +process.exit(1); diff --git a/scripts/worker.ts b/scripts/worker.ts index 38875f1..e0b2c7b 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -1,76 +1,73 @@ import path from "node:path"; -import { createFileDb } from "../src/infra/db/pglite"; -import { createPostgresDb } from "../src/infra/db/postgres"; +import { jobRunsInline } from "../src/config/env"; +import { pendingMigrations } from "../src/infra/db/migration-manifest"; import { createCoordinator } from "../src/infra/redis"; import { createStorage } from "../src/infra/storage"; -import { migrate } from "../src/infra/db/migrate"; -import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job"; -import { asJson } from "../src/server/spreadsheet-adapter/db-utils"; -import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types"; +import { createWorker } from "../src/server/spreadsheet-adapter/worker-loop"; +import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap"; -import { assertProductionEnv } from "../src/config/env"; +/** + * Processo do worker. Só bootstrap, sinais e cadência: a lógica do tick está em + * `src/server/spreadsheet-adapter/worker-loop.ts`, que é testável sem subir + * processo. + * + * Precisa ser executado com `node --import tsx scripts/worker.ts`, não com o + * CLI do `tsx`: o CLI cria um processo filho e o SIGTERM do contêiner pararia + * no pai, sem nunca chegar ao handler que espera o lock ser liberado. + */ -assertProductionEnv(process.env); -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl && process.env.NODE_ENV === "production") { - throw new Error("DATABASE_URL é obrigatório para o worker."); -} -const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -await migrate(db); -const deps = { db, storage: createStorage(), coordinator: createCoordinator() }; +const TICK_MS = Number(process.env.ADAPTER_WORKER_TICK_MS ?? 2000); +const BATCH = Number(process.env.ADAPTER_WORKER_BATCH ?? 20); +const GRACE_MS = Number(process.env.ADAPTER_WORKER_GRACE_MS ?? 20000); +const HEARTBEAT = process.env.ADAPTER_WORKER_HEARTBEAT ?? path.join("/tmp", "adaptador-worker-heartbeat"); -async function tick() { - await recoverStaleJobs(deps).catch((error) => { - console.error("worker", error instanceof Error ? error.name : "error"); - }); - await cleanupExpired(deps).catch((error) => { - console.error("worker", "cleanup", error instanceof Error ? error.name : "error"); - }); - const queued = new Set(await listQueuedJobIds(deps)); - for (let index = 0; index < 20; index += 1) { - const jobId = await deps.coordinator.dequeue().catch(() => null); - if (!jobId) break; - queued.add(jobId); - } - for (const jobId of queued) { - const rows = await readJobForWorker(deps, jobId); - const row = rows[0]; - if (!row) continue; - const progress = asJson(row.progress, null); - if (!progress?.sourceId) continue; - const profile = await db.withService((query) => - query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>( - `select p.display_name, p.is_superadmin, w.name as workspace_name, m.role, - coalesce(f.enabled, false) as module_enabled, - coalesce(s.allow_non_admin_model_upload, false) as allow_upload - from public.profiles p - join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2 - join public.workspaces w on w.id = $2 - left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' - left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id - where p.id = $1`, - [row.user_id, row.workspace_id], - ), - ); - const found = profile[0]; - if (!found) continue; - const ctx: RequestContext = { - userId: row.user_id, - displayName: found.display_name, - isSuperadmin: found.is_superadmin, - workspaceId: row.workspace_id, - workspaceName: found.workspace_name, - role: found.role, - moduleEnabled: found.module_enabled, - allowNonAdminModelUpload: found.allow_upload, - }; - await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => { - console.error("worker", error instanceof Error ? error.name : "error"); - }); - } +bootstrapScript("worker"); +if (jobRunsInline()) { + console.warn("[worker] ADAPTER_JOB_MODE não é worker. Este processo só é útil com a fila ligada."); } -await tick(); -setInterval(() => { - void tick(); -}, 2000); +runScript("worker", async () => { + const db = await openOperationalDb(); + + // O worker não aplica migrations: isso é passo explícito e aprovado do deploy. + const pending = await pendingMigrations(db); + if (pending.length > 0) { + await db.close().catch(() => undefined); + throw new Error(`migrations pendentes em ${describeTarget()}: ${pending.join(", ")}. Rode npm run migrate antes.`); + } + + const deps = { db, storage: createStorage(), coordinator: createCoordinator() }; + const worker = createWorker(deps, { + batch: BATCH, + heartbeatPath: HEARTBEAT, + onError: (scope, error) => { + // Nome e escopo do erro. Nunca conteúdo de célula. + console.error("worker", scope, error instanceof Error ? error.name : "error"); + }, + }); + + const timer = setInterval(() => { + void worker.tick(); + }, TICK_MS); + + let stopping = false; + async function shutdown(signal: string) { + if (stopping) return; + stopping = true; + console.log(`[worker] ${signal} recebido. Encerrando.`); + clearInterval(timer); + const clean = await worker.shutdown(GRACE_MS); + if (!clean) console.warn(`[worker] tick não terminou em ${GRACE_MS}ms. Encerrando de todo modo.`); + await db.close().catch(() => undefined); + console.log("[worker] encerrado."); + process.exit(0); + } + + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); + + console.log( + `[worker] pronto. intervalo=${TICK_MS}ms lote=${BATCH} carencia=${GRACE_MS}ms alvo=${describeTarget()} heartbeat=${HEARTBEAT}`, + ); + await worker.tick(); +}); diff --git a/src/server/spreadsheet-adapter/process-job.ts b/src/server/spreadsheet-adapter/process-job.ts index ae018ed..cf2b106 100644 --- a/src/server/spreadsheet-adapter/process-job.ts +++ b/src/server/spreadsheet-adapter/process-job.ts @@ -7,7 +7,6 @@ import { canUseSpreadsheetAdapter } from "@/domain/spreadsheet-adapter/permissio import { transformRows } from "@/domain/spreadsheet-adapter/transform"; import type { JobProgress, OutputFormat, RequestContext } from "@/domain/spreadsheet-adapter/types"; import { buildCsv, buildXlsx } from "@/infra/export/tabular"; -import { buildPdf } from "@/infra/export/pdf"; import type { Query } from "@/infra/db/types"; import { lockKey } from "@/infra/redis/redis"; import { sanitizeAuditMetadata } from "@/infra/observability/scrub"; @@ -282,6 +281,10 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8"); const grid = parseCsvText(csv); const headers = grid[0] ?? []; + // Import tardio: o worker não gera PDF (o PDF sai sob demanda no download), + // e carregar @react-pdf/renderer fora do bundler do Next falha ao resolver + // o subpath de hifenização. Sem isto o processo do worker nem subia. + const { buildPdf } = await import("@/infra/export/pdf"); const body = await buildPdf(file.model_name, headers, grid.slice(1)); return { filename, contentType: CONTENT_TYPE.pdf, body: body.body }; } diff --git a/src/server/spreadsheet-adapter/worker-loop.ts b/src/server/spreadsheet-adapter/worker-loop.ts new file mode 100644 index 0000000..893ccfb --- /dev/null +++ b/src/server/spreadsheet-adapter/worker-loop.ts @@ -0,0 +1,176 @@ +import { asJson } from "./db-utils"; +import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "./process-job"; +import type { AppDeps } from "./deps"; +import type { JobProgress, RequestContext, Role } from "@/domain/spreadsheet-adapter/types"; + +/** + * Loop do worker, fora do script de entrada para poder ser testado sem subir + * um processo. O script `scripts/worker.ts` só cuida de sinais e de bootstrap. + * + * Sem `import "server-only"` de propósito: este módulo roda também no processo + * do worker, que é Node puro. O pacote `server-only` lança quando é carregado + * fora do bundler do Next, e isso derrubava o worker no start. + */ + +export type WorkerOptions = { + /** Máximo de ids retirados da fila do Redis por tick. */ + batch?: number; + /** Caminho do arquivo de heartbeat lido pelo health check do contêiner. */ + heartbeatPath?: string | null; + /** Injetável para teste. */ + writeHeartbeat?: (path: string) => Promise; + log?: (message: string) => void; + onError?: (scope: string, error: unknown) => void; +}; + +export type TickResult = { + /** Jobs que o worker tentou processar neste tick. */ + processed: string[]; + /** Temporários expirados removidos neste tick. */ + cleaned: number; + recovered: boolean; +}; + +const DEFAULT_BATCH = 20; + +async function defaultWriteHeartbeat(target: string): Promise { + const { writeFile } = await import("node:fs/promises"); + await writeFile(target, new Date().toISOString(), "utf8"); +} + +/** + * Contexto do dono do job, relido do banco a cada execução. Nunca vem do + * payload do job: papel, flag do módulo e configuração podem ter mudado entre + * o enfileiramento e o processamento. + */ +async function loadContext(deps: AppDeps, userId: string, workspaceId: string): Promise { + const rows = await deps.db.withService((query) => + query.query<{ + display_name: string; + is_superadmin: boolean; + role: Role; + workspace_name: string; + module_enabled: boolean; + allow_upload: boolean; + }>( + `select p.display_name, p.is_superadmin, w.name as workspace_name, m.role, + coalesce(f.enabled, false) as module_enabled, + coalesce(s.allow_non_admin_model_upload, false) as allow_upload + from public.profiles p + join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2 + join public.workspaces w on w.id = $2 + left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' + left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id + where p.id = $1`, + [userId, workspaceId], + ), + ); + const found = rows[0]; + if (!found) return null; + return { + userId, + displayName: found.display_name, + isSuperadmin: found.is_superadmin, + workspaceId, + workspaceName: found.workspace_name, + role: found.role, + moduleEnabled: found.module_enabled, + allowNonAdminModelUpload: found.allow_upload, + }; +} + +export function createWorker(deps: AppDeps, options: WorkerOptions = {}) { + const batch = options.batch ?? DEFAULT_BATCH; + const heartbeatPath = options.heartbeatPath ?? null; + const writeHeartbeat = options.writeHeartbeat ?? defaultWriteHeartbeat; + const onError = options.onError ?? (() => undefined); + + let running = true; + let inTick = false; + + async function tick(): Promise { + if (!running || inTick) return { processed: [], cleaned: 0, recovered: false }; + inTick = true; + const processed: string[] = []; + let cleaned = 0; + let recovered = false; + try { + recovered = await recoverStaleJobs(deps) + .then(() => true) + .catch((error) => { + onError("recover", error); + return false; + }); + cleaned = await cleanupExpired(deps).catch((error) => { + onError("cleanup", error); + return 0; + }); + + // Duas origens para o mesmo trabalho: a fila do Redis é o caminho rápido, + // a varredura do Postgres garante que nada se perde se o Redis reiniciar. + const queued = new Set( + await listQueuedJobIds(deps).catch((error) => { + onError("list", error); + return [] as string[]; + }), + ); + for (let index = 0; index < batch; index += 1) { + const jobId = await deps.coordinator.dequeue().catch(() => null); + if (!jobId) break; + queued.add(jobId); + } + + for (const jobId of queued) { + if (!running) break; + const rows = await readJobForWorker(deps, jobId).catch((error) => { + onError("read", error); + return []; + }); + const row = rows[0]; + if (!row) continue; + const progress = asJson(row.progress, null); + if (!progress?.sourceId) continue; + const ctx = await loadContext(deps, row.user_id, row.workspace_id).catch((error) => { + onError("context", error); + return null; + }); + if (!ctx) continue; + await runProcess(deps, ctx, jobId, progress.sourceId) + .then(() => { + processed.push(jobId); + }) + .catch((error) => { + onError("process", error); + }); + } + + if (heartbeatPath) { + await writeHeartbeat(heartbeatPath).catch((error) => onError("heartbeat", error)); + } + } finally { + inTick = false; + } + return { processed, cleaned, recovered }; + } + + /** + * Para de aceitar trabalho novo e espera o tick corrente terminar, para que o + * `finally` do processamento libere o lock. Sem essa espera o job ficaria em + * `processing` até o lock vencer por TTL. + */ + async function shutdown(graceMs = 20_000, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise { + running = false; + const deadline = Date.now() + graceMs; + while (inTick && Date.now() < deadline) { + await sleep(100); + } + return !inTick; + } + + return { + tick, + shutdown, + isRunning: () => running, + isBusy: () => inTick, + }; +}