test(rollback): documenta ensaio isolado de recuperacao
This commit is contained in:
1 parent
bd0ecabf41
commit
4779d6b2a3
4 files changed
+447
No files matched your search
@@ -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
|
||||||
@@ -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-<sha>`, `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.
|
||||||
@@ -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<Buffer> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string, { status: number; sha256: string; size: number }> = {};
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, { sha256: string }>;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"scripts/smoke-local.ts",
|
"scripts/smoke-local.ts",
|
||||||
|
"scripts/rollback-local-probe.ts",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user