diff --git a/docker-compose.rollback-local.yml b/docker-compose.rollback-local.yml new file mode 100644 index 0000000..27e5c17 --- /dev/null +++ b/docker-compose.rollback-local.yml @@ -0,0 +1,111 @@ +# Ensaio descartável de rollback de imagem. +# +# Projeto, rede e volumes são exclusivos deste ensaio. Não há `build:` de +# propósito: a imagem vem só de ADAPTER_IMAGE, com tag imutável do commit. +# Nunca use a tag `adaptador-planilhas:local` aqui — ela alimenta o compose +# principal. Postgres e Redis não publicam porta. + +name: adaptador-rollback-ensaio + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: adaptador + POSTGRES_PASSWORD: rollback_ensaio_local + POSTGRES_DB: adaptador_rollback + volumes: + - ensaio-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U adaptador -d adaptador_rollback"] + interval: 5s + timeout: 3s + retries: 20 + networks: [ensaio] + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + networks: [ensaio] + + storage-init: + image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit} + command: ["prepare-storage"] + user: "0:0" + network_mode: none + cap_drop: [ALL] + cap_add: [CHOWN, FOWNER, DAC_OVERRIDE, DAC_READ_SEARCH, SETUID, SETGID] + security_opt: ["no-new-privileges:true"] + healthcheck: + disable: true + restart: "no" + volumes: + - ensaio-storage:/app/.data + + web: + image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit} + command: ["web"] + environment: &app-env + ADAPTER_ENV: development + ADAPTER_NAMESPACE: adaptador-rollback-ensaio + ADAPTER_JOB_MODE: worker + ADAPTER_APPLY_IDENTITY_SHIM: "1" + ADAPTER_DEV_AUTH: "1" + ADAPTER_DEV_AUTH_SECRET: segredo-apenas-ensaio + DATABASE_URL: postgres://adaptador:rollback_ensaio_local@postgres:5432/adaptador_rollback + REDIS_URL: redis://redis:6379 + SPREADSHEET_TEMP_TTL_SECONDS: "3600" + ELO_PRO_APP_URL: http://127.0.0.1:3310 + PORT: "3000" + HOSTNAME: 0.0.0.0 + ports: + - "127.0.0.1:3310:3000" + volumes: + - ensaio-storage:/app/.data + depends_on: + storage-init: + condition: service_completed_successfully + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: [ensaio] + + worker: + image: ${ADAPTER_IMAGE:?defina ADAPTER_IMAGE com a tag do commit} + command: ["worker"] + environment: *app-env + volumes: + - ensaio-storage:/app/.data + healthcheck: + test: ["CMD", "/app/docker/entrypoint.sh", "worker-health"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + stop_grace_period: 30s + depends_on: + storage-init: + condition: service_completed_successfully + web: + condition: service_started + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: [ensaio] + +networks: + ensaio: + name: adaptador-rollback-ensaio + +volumes: + ensaio-postgres: + name: adaptador-rollback-ensaio-postgres + ensaio-storage: + name: adaptador-rollback-ensaio-storage diff --git a/docs/spreadsheet-adapter/25-rollback-local.md b/docs/spreadsheet-adapter/25-rollback-local.md new file mode 100644 index 0000000..4030fff --- /dev/null +++ b/docs/spreadsheet-adapter/25-rollback-local.md @@ -0,0 +1,136 @@ +# Ensaio local de rollback de imagem + +Ensaio descartável feito em 17 de outubro de 2026, nesta máquina, no projeto Docker `adaptador-rollback-ensaio`. Não é o rollback da homologação nem o da produção da Elo Pro. + +O gate F11 do [checklist](21-checklist-validacao.md) continua **pendente**. Ele exige duas imagens publicadas e o ambiente `hml-ada.elopro.app`. O procedimento de lá está na seção 10 de [20-homologacao-runbook.md](20-homologacao-runbook.md). Este documento não o aprova. + +## O que foi comparado + +| | Atual | Anterior | +| --- | --- | --- | +| Commit | `bd0ecabf41330d8e48dbe070cb090b25fd6eb64c` | `2be97ea9694eb3fe44e125fbd2ab3e13d8cc488a` | +| Tag da imagem | `adaptador-planilhas:bd0ecab` | `adaptador-planilhas:2be97ea` | +| Id da imagem | `sha256:bc06e875c96b2a1f28b406fd0512e40b37399550355f204d32d77ae766db9eed` | `sha256:edc36b8532b172ea940ed6dd252e2f965a91694366adb5828e87f7c72dc520d9` | + +`git diff 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml` não tem diferença. O commit atual só acrescenta o smoke local, o `package.json`, o `tsconfig.json` e a documentação. As quatro migrations (`0001` a `0004`) são as mesmas. Por isso a imagem anterior convive com o schema aplicado pela imagem atual. Não houve downgrade de migration. + +A tag `adaptador-planilhas:local` do compose principal não foi reescrita. Antes e depois do ensaio ela continuou `sha256:1efe0dfab0a80518563bd8ac2905466eca5692c05a26818a397551fffc05f1fa`, com web, worker, Postgres e Redis principais saudáveis. O banco `adaptador` seguiu com 1 modelo e as flags `spreadsheet_adapter` Alpha ligada e Beta desligada. O comando `flag` não foi executado. + +## Isolamento + +Arquivo: `docker-compose.rollback-local.yml`. + +| Recurso | Nome | Publicado no host | +| --- | --- | --- | +| Projeto | `adaptador-rollback-ensaio` | não | +| Rede | `adaptador-rollback-ensaio` | não | +| Volume Postgres | `adaptador-rollback-ensaio-postgres` | não | +| Volume storage | `adaptador-rollback-ensaio-storage` | não | +| Banco | `adaptador_rollback` | não | +| Redis | interno | não | +| Web | `127.0.0.1:3310` | só loopback | + +Não há `build:` nesse compose. Sem `ADAPTER_IMAGE` ele recusa subir, para não cair na tag `:local`. Postgres e Redis não têm porta no host. A sonda `scripts/rollback-local-probe.ts` só aceita `http://127.0.0.1:3310`. + +Os volumes `adaptador-planilhas-local-postgres` e `adaptador-planilhas-local-storage` não entraram no ensaio. Não houve `docker compose down -v` no projeto principal, nem `git reset`. + +## Comandos executados + +Conferência de compatibilidade, sem trocar de branch: + +```bash +git diff --stat 2be97ea bd0ecab -- src migrations Dockerfile docker docker-compose.yml +git worktree add --detach "$TEMP/adaptador-2be97ea-src" 2be97ea +``` + +Imagens, sem mover a tag `:local`: + +```bash +docker build -t adaptador-planilhas:bd0ecab --build-arg SENTRY_RELEASE=bd0ecabf41330d8e48dbe070cb090b25fd6eb64c --target runner . +docker build -t adaptador-planilhas:2be97ea --build-arg SENTRY_RELEASE=2be97eaf --target runner "$TEMP/adaptador-2be97ea-src" +``` + +Subida do ensaio e migration única, com a imagem atual: + +```bash +export ADAPTER_IMAGE=adaptador-planilhas:bd0ecab +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d postgres redis storage-init +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml run --rm --no-deps web migrate +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d web worker +``` + +A migration só rodou depois do Postgres saudável. Aplicou `0001_platform_shim.sql`, `0002_spreadsheet_adapter.sql`, `0003_rls.sql` e `0004_audit_append_only.sql`. Não existe passo de down. `0003` e `0004` continuam sem caminho de volta, como no runbook. + +Jornada fictícia: + +```bash +node --import=tsx scripts/rollback-local-probe.ts seed +``` + +Troca só de web e worker, volumes intactos: + +```bash +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml stop -t 30 web worker +export ADAPTER_IMAGE=adaptador-planilhas:2be97ea +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml up -d --no-deps --force-recreate web worker +``` + +Volta para a imagem atual, do mesmo jeito, com `ADAPTER_IMAGE=adaptador-planilhas:bd0ecab`. + +Depois das evidências, só o projeto do ensaio foi removido: + +```bash +docker compose -p adaptador-rollback-ensaio -f docker-compose.rollback-local.yml down -v +git worktree remove --force "$TEMP/adaptador-2be97ea-src" +``` + +Antes do `down -v`, os nomes listados eram apenas `adaptador-rollback-ensaio`, `adaptador-rollback-ensaio-postgres` e `adaptador-rollback-ensaio-storage`. As imagens `:bd0ecab` e `:2be97ea` foram mantidas. A tag `:local` não. + +## Resultado + +Prontidão da imagem atual, antes da troca: HTTP 200, `status=ready`, `environment=development`, e `config`, `database`, `migrations`, `redis`, `storage` e `job_mode` em `ok`. + +Dados fictícios gravados: + +| Campo | Valor | +| --- | --- | +| Modelo | `52add4ba-88b5-4611-b8d7-4ffe33273d3e` | +| Operação | `ee5fb92f-57ba-4414-a7ab-b9635e243aea` | +| Fonte | `eabec970-e86d-4b39-8c76-c2b5d10b4674` | +| SHA-256 do XLSX original | `74a4130f7712f94727c6bbf8708b9e77fd34e61c7504da8b84be309cbd6d9559` | +| SHA-256 do XLSX exportado | `89abbca277ea2569bf63ac5cffcd581181b5b6e97d22a285cf21786e175608dc` | +| SHA-256 do CSV exportado | `199e83c30d6fbae7174a4ecd563d32e9ece19a677209e79e88bb56116a6b8977` | + +O mesmo SHA-256 do original foi lido no volume pela imagem atual e, depois da troca, pela imagem `2be97ea`. O `content_sha256` no banco isolado coincidiu. `schema_migrations` permaneceu nas quatro migrations. As flags do banco isolado nasceram do login local de desenvolvimento (Alpha ligada, Beta desligada) e não mudaram na troca de imagem. + +Na imagem anterior e de novo na atual: + +- `GET /api/health/ready` voltou `ready`. +- O modelo foi encontrado pelo id e pelo nome. +- A operação permaneceu `completed`. +- Os bytes armazenados de XLSX e CSV repetiram o SHA-256. +- O PDF voltou HTTP 200. O SHA-256 do PDF não se repete: ele é gerado no download, não fica gravado. Isso ocorreu nas duas imagens, não é incompatibilidade de rollback. +- O worker da imagem anterior registrou `[worker] pronto`. Depois da restauração, o healthcheck do worker ficou `healthy`. + +Tempo observado da troca, do `stop` até o `ready`, no ensaio local: cerca de 3 s na ida para `2be97ea` e 2,8 s na volta para `bd0ecab`. Esse número não descreve pull de registry, Nginx nem a flag do piloto. + +Nenhuma incompatibilidade de schema apareceu. O rollback não foi interrompido. + +## Como repetir a recuperação + +1. Anote a imagem que está no ar. No ensaio, o retorno é `adaptador-planilhas:bd0ecab`. +2. Pare só `web` e `worker` do projeto isolado, com `stop -t 30`. +3. Aponte `ADAPTER_IMAGE` para a tag do commit desejado. +4. Suba de novo com `up -d --no-deps --force-recreate web worker`. +5. Não rode migration de down. Se `migrations` não ficar `ok`, ou se o processo não subir, interrompa e volte para a tag atual pelo mesmo caminho. Schema incompatível é operação planejada, com backup, não rollback às pressas. +6. Confira `/api/health/ready`, o modelo, o SHA-256 do original no volume e o download do XLSX e do CSV. + +Na homologação real o equivalente está no runbook: tag `staging-`, `up -d --no-deps web worker`, e a flag do módulo é outro passo, feito com `flag`, antes da troca e só religada depois da saúde. Este ensaio não fez isso e não deve ser copiado para o servidor compartilhado. + +## Limitações + +- As duas imagens executam o mesmo código de aplicação. O ensaio prova a troca de imagem com volume e schema preservados. Não prova um rollback atravessando uma migration nova. +- Não usa Supabase, SSO, Sentry nem bucket da Elo Pro. +- A flag do piloto não foi ligada nem desligada. +- O PDF não serve como hash de integridade entre downloads. +- O tempo de 3 s é só o recreate local das duas imagens já presentes no Docker. diff --git a/scripts/rollback-local-probe.ts b/scripts/rollback-local-probe.ts new file mode 100644 index 0000000..02eeeff --- /dev/null +++ b/scripts/rollback-local-probe.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import ExcelJS from "exceljs"; + +/** + * Jornada fictícia contra o ensaio de rollback, só em 127.0.0.1:3310. + * Recusa a porta 3000 do compose principal. Não abre banco nem Docker. + */ + +const ALPHA = "11111111-1111-4111-8111-111111111111"; +const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const base = process.env.ROLLBACK_PROBE_URL ?? "http://127.0.0.1:3310"; +const mode = process.argv[2]; +const expectedRaw = process.argv[3]; + +function assertTarget(): void { + const url = new URL(base); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.port !== "3310") { + throw new Error("alvo recusado: o ensaio só fala com http://127.0.0.1:3310"); + } +} + +function sha256(body: Buffer): string { + return createHash("sha256").update(body).digest("hex"); +} + +async function xlsx(rows: string[][]): Promise { + const book = new ExcelJS.Workbook(); + const sheet = book.addWorksheet("Dados"); + for (const row of rows) sheet.addRow(row); + return Buffer.from(await book.xlsx.writeBuffer()) as Buffer; +} + +async function login(): Promise { + const response = await fetch(`${base}/api/dev/session`, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ userId: ANA, workspaceId: ALPHA }), + signal: AbortSignal.timeout(30_000), + }); + if (response.status !== 200) throw new Error(`sessão local HTTP ${response.status}`); + const lines = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : []; + const pair = lines.map((line) => line.split(";")[0]).find((line) => line.startsWith("adapter_dev_session=")); + if (!pair) throw new Error("cookie de sessão ausente"); + return pair; +} + +async function request(path: string, cookie: string, init?: RequestInit): Promise<{ status: number; body: unknown; bytes: Buffer; headers: Headers }> { + const headers = new Headers(init?.headers); + headers.set("cookie", cookie); + headers.set("accept", headers.get("accept") ?? "application/json"); + const response = await fetch(`${base}${path}`, { ...init, headers, signal: AbortSignal.timeout(60_000) }); + const bytes = Buffer.from(await response.arrayBuffer()); + let body: unknown = bytes.toString("utf8"); + try { + body = JSON.parse(bytes.toString("utf8")); + } catch { + /* arquivo binário */ + } + return { status: response.status, body, bytes, headers: response.headers }; +} + +async function waitJob(cookie: string, jobId: string): Promise { + let status = ""; + for (let attempt = 0; attempt < 45; attempt += 1) { + const polled = await request(`/api/spreadsheet-adapter/jobs/${jobId}`, cookie); + status = (polled.body as { job?: { status?: string } }).job?.status ?? ""; + if (status === "completed" || status === "failed") return status; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + return status || "timeout"; +} + +async function downloads(cookie: string, jobId: string, sourceId: string) { + const files: Record = {}; + for (const format of ["xlsx", "csv", "pdf"]) { + const file = await request(`/api/spreadsheet-adapter/jobs/${jobId}/download?format=${format}&sourceId=${sourceId}`, cookie, { + headers: { accept: "*/*" }, + }); + files[format] = { status: file.status, sha256: sha256(file.bytes), size: file.bytes.length }; + } + return files; +} + +async function seed(): Promise { + const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0"); + const modelName = `Rollback local ${stamp}-${suffix}`; + const sentinel = `ROLLBACKLOCAL${suffix.toUpperCase()}`; + const cookie = await login(); + const modelBytes = await xlsx([["Nome", "Codigo"]]); + const sourceBytes = await xlsx([ + ["Nome", "Codigo"], + ["Registro ficticio", sentinel], + ]); + const modelForm = new FormData(); + modelForm.append("name", modelName); + modelForm.append("requiredColumns", JSON.stringify(["Nome", "Codigo"])); + modelForm.append("file", new File([new Uint8Array(modelBytes)], "modelo-ficticio.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })); + const created = await request("/api/spreadsheet-adapter/models", cookie, { method: "POST", body: modelForm, headers: { accept: "application/json" } }); + const modelId = (created.body as { model?: { id?: string } }).model?.id ?? ""; + if (created.status !== 201 || !modelId) throw new Error(`modelo HTTP ${created.status}`); + const job = await request("/api/spreadsheet-adapter/jobs", cookie, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId, idempotencyKey: `rollback-job-${suffix}` }), + }); + const jobId = (job.body as { job?: { id?: string } }).job?.id ?? ""; + if (job.status !== 201 || !jobId) throw new Error(`operação HTTP ${job.status}`); + const sourceForm = new FormData(); + sourceForm.append("jobId", jobId); + sourceForm.append("file", new File([new Uint8Array(sourceBytes)], "fonte-ficticia.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })); + const uploaded = await request("/api/spreadsheet-adapter/sources/validate", cookie, { method: "POST", body: sourceForm, headers: { accept: "application/json" } }); + const sourceId = (uploaded.body as { job?: { sources?: { id?: string }[] } }).job?.sources?.[0]?.id ?? ""; + if (uploaded.status !== 200 || !sourceId) throw new Error(`fonte HTTP ${uploaded.status}`); + const mapped = await request(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, cookie, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sourceId, + mappings: [ + { sourceColumn: "Nome", modelColumn: "Nome" }, + { sourceColumn: "Codigo", modelColumn: "Codigo" }, + ], + }), + }); + if (mapped.status !== 200) throw new Error(`mapeamento HTTP ${mapped.status}`); + const preview = await request(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, cookie, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sourceId }), + }); + const previewRows = (preview.body as { rows?: string[][] }).rows ?? []; + if (preview.status !== 200 || !previewRows.some((row) => row.includes(sentinel))) throw new Error(`prévia HTTP ${preview.status}`); + const queued = await request(`/api/spreadsheet-adapter/jobs/${jobId}/process`, cookie, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sourceId, idempotencyKey: `rollback-proc-${suffix}` }), + }); + const immediate = (queued.body as { job?: { status?: string } }).job?.status ?? ""; + if (queued.status !== 200 || (immediate !== "queued" && immediate !== "processing")) throw new Error(`processo HTTP ${queued.status} status=${immediate}`); + const finalStatus = await waitJob(cookie, jobId); + if (finalStatus !== "completed") throw new Error(`worker status=${finalStatus}`); + const files = await downloads(cookie, jobId, sourceId); + if (Object.values(files).some((file) => file.status !== 200 || file.size < 1)) throw new Error("download incompleto"); + console.log(JSON.stringify({ modelName, modelId, jobId, sourceId, modelSha256: sha256(modelBytes), sentinel, files })); +} + +async function verify(): Promise { + if (!expectedRaw) throw new Error("informe o JSON gravado na subida"); + const expected = JSON.parse(expectedRaw.startsWith("{") ? expectedRaw : readFileSync(expectedRaw, "utf8")) as { + modelName: string; + modelId: string; + jobId: string; + sourceId: string; + modelSha256: string; + files: Record; + }; + const cookie = await login(); + const ready = await request("/api/health/ready", cookie); + const models = await request("/api/spreadsheet-adapter/models", cookie); + const list = (models.body as { models?: { id?: string; name?: string }[] }).models ?? []; + const found = list.some((model) => model.id === expected.modelId && model.name === expected.modelName); + const job = await request(`/api/spreadsheet-adapter/jobs/${expected.jobId}`, cookie); + const status = (job.body as { job?: { status?: string } }).job?.status ?? ""; + const files = await downloads(cookie, expected.jobId, expected.sourceId); + const stored = (["xlsx", "csv"] as const).every((format) => files[format].sha256 === expected.files[format].sha256 && files[format].status === 200); + const pdf = files.pdf.status === 200 && files.pdf.size > 4; + const report = { + ready: (ready.body as { status?: string }).status ?? ready.status, + modelFound: found, + jobStatus: status, + storedFilesMatch: stored, + pdfRegenerated: pdf, + sha256: { + xlsx: files.xlsx.sha256 === expected.files.xlsx.sha256, + csv: files.csv.sha256 === expected.files.csv.sha256, + pdf: files.pdf.sha256 === expected.files.pdf.sha256, + }, + }; + console.log(JSON.stringify(report)); + if (report.ready !== "ready" || !found || status !== "completed" || !stored || !pdf) process.exitCode = 1; +} + +assertTarget(); +if (mode === "seed") { + seed().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "erro"); + process.exit(1); + }); +} else if (mode === "verify") { + verify().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "erro"); + process.exit(1); + }); +} else { + console.error("uso: rollback-local-probe.ts seed|verify [json]"); + process.exit(1); +} diff --git a/tsconfig.json b/tsconfig.json index 4eb4ab4..2dc407c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "src/**/*.ts", "src/**/*.tsx", "scripts/smoke-local.ts", + "scripts/rollback-local-probe.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ],