fix(docker): inicializar permissoes do storage com seguranca
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-16 23:47:38 -03:00
1 parent e142a44c01
commit 2be97ea969
8 files changed
+421 -3

No files matched your search

@@ -81,6 +81,8 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
| D9d | Worker relê o contexto do dono | PASS | flag do módulo desligada entre o enfileiramento e o tick: o job não é processado |
| D10 | Worker recusa iniciar com migration pendente | PASS por construção | `pendingMigrations` no start; a execução real é PENDENTE |
| D11 | Desligamento gracioso do worker | PASS no loop / PENDENTE no sinal | `shutdown()` para de aceitar trabalho, espera o tick corrente sair, e o job que não começou fica `queued` com o lock reservado para ele; concluído, o lock é liberado. A **entrega do SIGTERM** ao processo é PENDENTE: no Windows não há sinal real, e o job `image` do CI confere `SIGTERM recebido`, `[worker] encerrado` e exit 0 em menos de 25 s |
| D19 | Storage local gravável pelo uid 1001 em volume novo e existente (local) | PASS | 16 out 2026, Docker Desktop/WSL2: `.github/scripts/storage-permissions.sh` casos A–F em volumes descartáveis. Corrige `STORAGE_NOT_WRITABLE` no upload. Ver [23-storage-permissions.md](23-storage-permissions.md). Não se aplica a homologação (bucket Supabase, D7) |
| D20 | Web e worker recusam subir com storage local sem escrita | PASS | mesmo script, caso F: saída com código 1 e `STORAGE_NOT_WRITABLE` |
| D17 | Heartbeat do worker e healthcheck | PASS | worker no layout de runtime gravou o heartbeat; `docker/worker-health.mjs` aprovou com 1 s e reprova quando o arquivo falta |
| D12 | Download XLSX, CSV e PDF ponta a ponta | PASS no local / PENDENTE no ambiente | `tests/integration/journey.test.ts` cobre o fluxo; falta a jornada em homologação |
| D13 | Modelo original não é alterado | PASS | `tests/unit/xlsx-fixtures.test.ts` compara bytes |
@@ -121,7 +123,7 @@ Regra de preenchimento: **PASS** só com evidência executada. Sem infraestrutur
| | Quantidade |
| --- | --- |
| PASS com evidência executada | 54 |
| PASS com evidência executada | 56 |
| PENDENTE por falta de infraestrutura ou credencial | 20 |
| FAIL | 0 |
@@ -0,0 +1,115 @@
# Permissões do armazenamento local em Docker
Escopo: `docker-compose.yml` (desenvolvimento local). Homologação e produção usam o bucket privado do Supabase e não montam volume em `/app/.data` — `docker-compose.staging.yml` não foi alterado.
## Problema
Cadastro de `modelo_imoveis_teste.xlsx` no compose local respondeu `Não foi possível continuar — Erro interno.`
```
uid=1001(adaptador) gid=1001(adaptador)
drwxr-xr-x 2 root root /app/.data
STORAGE_NOT_WRITABLE
```
Contornado à mão no volume local com `chown 1001:1001` e `chmod 750` em `/app/.data`, depois `STORAGE_WRITABLE`. Esta correção elimina o passo manual.
## Causa raiz
1. A imagem não continha `/app/.data`. `LocalPrivateStorage` grava em `/app/.data/storage` e só cria o diretório em runtime.
2. Quando um volume nomeado é montado num caminho que não existe na imagem, o Docker cria o ponto de montagem como `root:root 755`. O volume vazio não tem de onde herdar dono.
3. Web e worker rodam como `adaptador` (1001) sem capacidades. Não escrevem num diretório de root.
4. O health check do storage local só fazia `mkdir -p`, que passa quando o diretório já existe. O processo subia saudável e o erro só aparecia no primeiro upload, como 500 genérico.
Reproduzido num volume descartável com a imagem anterior: `drwxr-xr-x 0 0 /app/.data` e `touch: Permission denied`.
## Correção
Duas camadas, porque `RUN chown` no Dockerfile sozinho não alcança volume que já existe.
| Camada | Arquivo | O que faz | Alcança |
| --- | --- | --- | --- |
| Imagem | `Dockerfile` | Cria `/app/.data/storage` com dono `adaptador` e modo `750`. Um volume nomeado **vazio** herda dono e modo na primeira montagem | Volumes novos |
| Inicialização | `docker-compose.yml` (`storage-init`) + `docker/entrypoint.sh prepare-storage` | Serviço de uma execução, antes de web e worker, que ajusta **só** `/app/.data` e `/app/.data/storage` e prova a escrita como uid 1001 | Volumes existentes, inclusive os criados antes desta correção |
| Falha segura | `docker/entrypoint.sh` (`web`, `worker`) | Antes de iniciar, grava e apaga um arquivo de prova em `/app/.data/storage`. Sem escrita, sai com código 1 e `STORAGE_NOT_WRITABLE` | Qualquer ambiente que use o storage local |
Alternativa A (preparar dentro do entrypoint de web e worker) foi descartada: exigiria iniciar os processos como root e rebaixar o usuário depois. A alternativa B mantém web e worker exatamente como antes — `USER adaptador`, sem capacidades — e concentra o único passo privilegiado num contêiner que termina.
## Fluxo de inicialização
```
postgres (healthy) ─┐
redis (healthy) ────┤
storage-init ───────┼─ completed_successfully ─► web ─► worker
(root, sem rede, │
termina) │
```
`storage-init`:
- `user: "0:0"`, `network_mode: none`, `cap_drop: [ALL]` e só `CHOWN`, `FOWNER`, `DAC_OVERRIDE`, `DAC_READ_SEARCH`, `SETUID`, `SETGID`; `no-new-privileges`.
- Recusa link simbólico em `/app/.data` ou `/app/.data/storage`.
- Muda dono/modo somente quando diferem de `1001:1001 750`, e registra cada mudança.
- **Não é recursivo.** Itens com outro dono dentro do volume são contados e reportados, não alterados.
- Prova a escrita com `setpriv --reuid 1001` e falha com `STORAGE_NOT_WRITABLE` se não conseguir. Falha do `storage-init` impede web e worker de subir.
- UID/GID vêm de `id -u adaptador` na própria imagem, a mesma fonte do `USER` do Dockerfile.
## Permissões esperadas
| Caminho | Dono | Modo |
| --- | --- | --- |
| `/app/.data` | `1001:1001` | `750` |
| `/app/.data/storage` | `1001:1001` | `750` |
| Subpastas e arquivos criados pela aplicação | `1001:1001` | padrão do processo (`755`/`644`), protegidos pelo `750` acima |
| Processo | Usuário | Capacidades efetivas |
| --- | --- | --- |
| web | `adaptador` (1001) | nenhuma |
| worker | `adaptador` (1001) | nenhuma |
| storage-init | root, só durante a preparação | as seis listadas acima |
Web e worker montam o mesmo volume `adaptador-planilhas-local-storage` em `/app/.data`. O volume não é servido por HTTP: o download continua passando pela rota autenticada.
## Volume novo × existente
- **Novo:** herda `1001:1001 750` da imagem; `storage-init` só confirma e não altera nada.
- **Existente com raiz de root:** `storage-init` ajusta os dois diretórios. Conteúdo, dono e modo dos arquivos existentes ficam como estavam.
- **Existente já corrigido à mão:** nenhuma mudança de dono; o modo de `/app/.data/storage` passa de `755` para `750` na primeira execução.
## Validação
```bash
docker compose --parallel 1 build web
ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh
```
O script usa apenas volumes e contêineres descartáveis com prefixo `adaptador-storage-test-` e remove só o que criou. Casos:
| Caso | Verifica |
| --- | --- |
| A | Volume novo: `1001:1001 750` sem `storage-init`, escrita pelo uid 1001, `STORAGE_WRITABLE` |
| B | Volume com raiz `root:root 755` e arquivos: diretórios corrigidos, conteúdo e dono dos arquivos intactos, item de root apenas reportado |
| C | Duas execuções extras: snapshot de dono, modo, tamanho e SHA-256 idêntico; nenhuma mudança registrada |
| C2 | `--fix-existing` ajusta o dono só quando pedido e preserva o conteúdo |
| D | Um contêiner grava, outro lê e apaga; `web` real sobe e responde liveness; `worker` real passa da verificação |
| E | web como 1001 com `CapEff` zero; imagem com `USER adaptador`; `prepare-storage` recusa rodar sem root |
| F | Volume sem escrita: `web` e `worker` saem com código 1 e `STORAGE_NOT_WRITABLE`; com Supabase configurado a verificação não bloqueia |
O job `image` do CI executa o mesmo script.
## Recuperação
Se `storage-init` reportar `AVISO: N item(ns) ... com outro dono`, os arquivos continuam íntegros, mas a aplicação pode não conseguir sobrescrevê-los ou apagá-los. Ajuste controlado, limitado ao volume, sem seguir link simbólico e sem atravessar outro sistema de arquivos — **execute só com autorização**:
```bash
docker compose run --rm storage-init prepare-storage --fix-existing
```
Se `storage-init` falhar com `STORAGE_NOT_WRITABLE`: confira `docker compose logs storage-init`. Causas prováveis são volume montado somente leitura ou driver de volume que não aceita `chown`. Não use `chmod 777` nem rode web/worker como root.
## Limitações
- Bind mount (`./pasta:/app/.data`) não herda dono da imagem. No Docker Desktop/WSL2 sobre o disco do Windows o `chown` pode não ter efeito; a falha segura impede o processo de subir em silêncio. O compose usa volume nomeado.
- `--fix-existing` é recursivo dentro do volume e por isso é manual.
- O ajuste do volume local real `adaptador-planilhas-local-storage` acontece quando os contêineres forem recriados com o compose novo. Esta validação não recriou os contêineres em execução.
- Não valida homologação: o storage de homologação é o bucket privado do Supabase (item D7 do checklist continua PENDENTE).