test(rollback): documenta ensaio isolado de recuperacao
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled

This commit is contained in:
Ferraz Fagundes committed 2026-10-17 01:04:30 -03:00
1 parent bd0ecabf41
commit 4779d6b2a3
4 files changed
+447

No files matched your search

+111
View File
@@ -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.
+199
View File
@@ -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);
}
+1
View File
@@ -33,6 +33,7 @@
"src/**/*.ts",
"src/**/*.tsx",
"scripts/smoke-local.ts",
"scripts/rollback-local-probe.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],