# ADR-030 — Object storage S3-like desde o dia zero (RustFS local, trocável por AWS S3)

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-16) |
| **Data** | 2026-10-15 |
| **Plano de origem** | `wiki/plan/04-runtime-containers.md` · `wiki/plan/19-media-gallery.md` |
| **Relacionadas** | ADR-002, ADR-005, ADR-006, ADR-007, ADR-019, ADR-024, ADR-026, ADR-017 |

## Contexto

Precisamos decidir **onde os arquivos vivem** (mídia, anexos, PDFs, datasets exportados). Duas forças
concorrem: (a) temos **muito armazenamento local** disponível por instância; (b) o cliente pode,
a qualquer momento, querer **AWS S3**. Se começarmos com disco local e depois migrarmos, o fluxo da
aplicação muda — exatamente o tipo de retrabalho que o ADR-001 (determinismo/simplicidade) quer evitar.

A referência `sucesu-cms`/Concordia já assume **S3** (Flysystem, `league/flysystem-aws-s3-v3`), com
`SecureMediaStorage` e variantes responsivas. O **MinIO** é o S3-local mais comum, mas é **AGPL-3.0**
— incompatível com a política de **licenças permissivas** (ADR-002). O **RustFS** é um object storage
S3-compatível, escrito em Rust, **Apache-2.0**, com modo *single-node* e imagem oficial
(`rustfs/rustfs`, portas `9000`/`9001`).

## Decisão

1. **S3 é a interface única de armazenamento desde o dia zero.** O domínio **não** conhece disco
   local; fala **S3** (Flysystem). Trocar o backend = mudar `.env` (**zero** mudança de código).
2. **Backend local de cada instância = RustFS** (Apache-2.0), container interno
   (`storage`), **pinado por digest**, modo *single-node*. Absorve a configuração **AWS-S3**
   (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT`, `AWS_BUCKET`,
   `AWS_DEFAULT_REGION`, `AWS_USE_PATH_STYLE_ENDPOINT=true`).
3. **Troca para o AWS S3 do cliente = só `.env`:** alterar endpoint/credenciais/região e
   `AWS_USE_PATH_STYLE_ENDPOINT=false`. **Nenhuma** alteração de código, migrations ou rotas.
4. **Container `storage` (`rustfs/rustfs`)** entra no runtime do ADR-005, **sem porta exposta**
   (rede `internal`); o console (`RUSTFS_CONSOLE_ENABLE`) fica **desligado** — só o perfil `debug`
   pode publicá-lo em `127.0.0.1:9001`. O S3 API (`:9000`) tem health em `/health` e `/health/ready`.
5. **Dados locais** em `${DATA_PATH}/rustfs/data` — default `./s3-local` dentro do repo (ignorado
   pelo git); em produção aponte `DATA_PATH` para fora da cota (ADR-006). `/logs` é
   **tmpfs** (efêmero, porque a imagem o possui como UID `10001`); o *bucket* é criado/garantido de
   forma idempotente pelo comando **`php artisan storage:ensure-bucket`**, chamado pelo `up_prod.sh`.
6. **O serviço Media** (ADR-026) grava **originais e variantes** (AVIF/WebP) no bucket S3 pelo
   **mesmo fluxo** (`MediaStorageInterface`); o portal serve via URL/streaming same-origin.
7. **Path-style** obrigatório (`AWS_USE_PATH_STYLE_ENDPOINT=true`) para RustFS; `false` para AWS.
8. **Object Lock (WORM)** existe no RustFS, mas **não é adotado** agora — permanece no backlog
   `cripto-auditoria-avancada.md` (ADR-017/ADR-019).
9. **Backup/DR** do bucket entra no backlog `backup-recuperacao.md` (snapshot do volume + cópia
   para fora do host).

## Configuração de referência (docs.rustfs.com)

Imagem `rustfs/rustfs` (**Apache-2.0**), **pinada por digest**, modo *single-node*. O container roda
como **non-root via Compose** (`user: "${UID}:${GID}"`) e o bind mount de dados pertence ao **usuário
do deploy** (o `up_prod.sh` faz `mkdir -p "$DATA_PATH/rustfs/data"`). O diretório `/logs` da imagem
pertence ao UID `10001` e **não** é gravável por outro UID — por isso é montado como **tmpfs**
(`/logs:mode=1777`). Validado: health `{"status":"ok"}`.

```yaml
storage:
  image: rustfs/rustfs:1.0.1-glibc@sha256:87763ed4dcb2bed3fc52cbf6cb7d865411e085b0b952dc78e50dffa9ece0ed9e
  restart: unless-stopped
  user: "${UID:-1000}:${GID:-1000}"
  tmpfs:
    - /logs:mode=1777
  command: ["/data"]
  environment:
    RUSTFS_VOLUMES: "/data"
    RUSTFS_ADDRESS: ":9000"            # S3 API
    RUSTFS_CONSOLE_ENABLE: "false"     # console desligado (só perfil debug publica :9001)
    RUSTFS_REGION: "us-east-1"
    RUSTFS_ACCESS_KEY: "${AWS_ACCESS_KEY_ID}"
    RUSTFS_SECRET_KEY: "${AWS_SECRET_ACCESS_KEY}"
    RUSTFS_HEALTH_ENDPOINT_ENABLE: "true"
    RUSTFS_OBS_LOGGER_LEVEL: "error"
    RUSTFS_CHECK_UPDATE: "false"       # air-gapped / sem chamada externa
  volumes:
    - ${DATA_PATH:-./s3-local}/rustfs/data:/data
  # sem `ports:` (rede `internal`)
  healthcheck:
    test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
    interval: 10s
    timeout: 3s
    retries: 10
```

Notas oficiais (docs.rustfs.com):

- **Path-style é obrigatório** no RustFS (`forcePathStyle: true` / `AWS_USE_PATH_STYLE_ENDPOINT=true`);
  `RUSTFS_SERVER_DOMAINS` só habilita *virtual-hosted style*.
- Região padrão `us-east-1` quando `RUSTFS_REGION` não é definida.
- Health: `/health` e `/health/ready` (habilitados por padrão).
- Bucket criado de forma **idempotente** via S3 API (`aws s3api create-bucket --endpoint-url ...`)
  ou SDK (`CreateBucket`) no bootstrap/`up_prod.sh`.
- Credenciais default `rustfsadmin`/`rustfsadmin` são **proibidas** fora de teste local.
- `RUSTFS_OBS_ENDPOINT` (OTLP/HTTP) só no perfil `telemetry` (ADR-024).

## Justificativa (alternativas descartadas)

- **Só disco local (Flysystem `local`)** — descartada: migrar para S3 depois **muda o fluxo**; fere
  determinismo/previsibilidade e exige retrabalho.
- **MinIO** — descartada: **AGPL-3.0** (copyleft), viola a política de licenças permissivas (ADR-002)
  para redistribuição multi-prefeitura.
- **Ceph / Swift** — descartadas: complexidade operacional desproporcional ao single-node.
- **Drive/NFS compartilhado** — descartada: acoplamento de host, sem semântica de objeto/versionamento.

## Consequências

**Positivas**
- Fluxo de arquivos **idêntico** em desenvolvimento, on-premise e AWS; troca real por `.env`.
- Licença **Apache-2.0** (permissiva) e imagem oficial única; leve e rápido (Rust).
- Semântica de objeto pronta para **versionamento/expansão** e futura trilha WORM.

**Custos / negativas**
- Mais **um** container no runtime (6 serviços) e um volume de dados a operar.
- Exige `league/flysystem-aws-s3-v3` (dependência **permitida**, ADR-002) e *path-style* correto.
- Operação de bucket/credenciais passa a ter um runbook (criação idempotente, rotação de chaves).

## Exemplo mínimo (comparativo)

```dotenv
# ✅ Local (RustFS) — mesmo fluxo do S3
AWS_ACCESS_KEY_ID=rustfsadmin
AWS_SECRET_ACCESS_KEY=change-me
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=public-v7
AWS_ENDPOINT=http://storage:9000
AWS_USE_PATH_STYLE_ENDPOINT=true

# ✅ AWS S3 do cliente — muda SÓ o .env
# AWS_ENDPOINT=            # vazio => usa o endpoint padrão da AWS
# AWS_USE_PATH_STYLE_ENDPOINT=false
# AWS_DEFAULT_REGION=sa-east-1
```
```php
// ✅ Domínio fala S3 via Flysystem — nunca disco local
Storage::disk('s3')->put($path, $contents);
// ❌ Storage::disk('local') no domínio de mídia (quebra a troca por AWS)
```

## Verificação

- `config/filesystems.php` com disco `s3` lendo `AWS_*`; **nenhum** uso de `disk('local')` no
  domínio de mídia (exceto quarentena de upload, que é transitória e limpa).
- `docker compose` inclui o serviço `storage` **sem porta publicada**; healthcheck responde.
- Teste: subir com RustFS e com um *fake* S3 (`Storage::fake('s3')`) — o mesmo teste passa.
- Trocar `AWS_ENDPOINT`/`AWS_USE_PATH_STYLE_ENDPOINT` **não** altera código (apenas `.env`).

## Referências

- `https://github.com/rustfs/rustfs` (Apache-2.0; `rustfs/rustfs`; portas 9000/9001);
  `wiki/plan/04-runtime-containers.md`; `wiki/plan/19-media-gallery.md`;
  `wiki/backlog/{backup-recuperacao,cripto-auditoria-avancada}.md`.

## Emenda 2026-10-21 — o bucket deixa de ser um ponto único de perda (D7)

O item 9 acima mandava o backup/DR para o backlog. O veredito do runtime (D7) reclassificou
como **P0**: o bucket da mídia é *single-drive* (EC:0) e **não tinha cópia nenhuma** — o
`pg_dump` do banco era feito todo dia, mas as imagens publicadas eram irrecuperáveis se o
host morresse. Esta emenda registra o que passou a ser verdade.

### O estado do bucket é aplicado e verificado, não é configuração

- **Versioning ligado** (`PutBucketVersioning`): o direito de desfazer um `delete` ou um
  *overwrite*. Só faz sentido depois que existe backup — e agora existe.
- **PublicAccessBlock com os quatro flags** (`BlockPublicAcls`, `IgnorePublicAcls`,
  `BlockPublicPolicy`, `RestrictPublicBuckets`): o bucket é **privado**. O app serve a mídia
  por *stream* com as próprias credenciais (nada legítimo depende de acesso público).
- Os dois são aplicados pelo **mesmo bootstrap** (`storage:ensure-bucket`, idempotente ✓)
  que o `up_prod.sh` e o `make check` chamam; o `--check` só verifica e falha alto quando o
  estado diverge. `RUSTFS_API_OBJECT_MAX_VERSIONS=20` limita as versões por objeto — com
  versionamento ligado, um arquivo reescrito 500 vezes não pode comer o disco.

### O backup da mídia: o cliente S3 do próprio app, nunca o `mc`

`php artisan media:backup` copia os objetos para o bucket **offsite** (`OFFSITE_MEDIA_BUCKET`,
mesmo endpoint/credenciais do `OFFSITE_*`, bucket **dedicado** para não misturar com os dumps
do banco) e grava o **manifesto** (`manifests/media-backup.json`): contagem, bytes e o
**SHA-256 de cada objeto**.

- O transporte é o **cliente S3 do app** (Flysystem/AWS SDK). O `mc` (MinIO client) é
  **AGPL** e violaria o ADR-002 — a alternativa está descartada, não adiada.
- **Idempotente**: o que já está no destino com o mesmo conteúdo não é reescrito (o backup
  roda todo dia; reescrever tudo todo dia seria desperdício).
- **Verificado**: depois de copiar, o destino é relido e o SHA-256 comparado — cópia
  truncada **falha**.
- **Restaurável**: `php artisan media:restore` devolve ao disco de destino **exatamente** o que
  o manifesto lista. Não é espelho: o que não está no manifesto não é tocado.
- O **SHA-256** (e não o ETag) é a prova: em *upload multipart* o ETag é a soma das partes,
  não o hash do conteúdo.
- Agendado **diariamente** (`media:backup` na agenda); quem roda a agenda é o cron do host.
- O `media:inventory` produz o **inventário de integridade** (objetos, tamanho e SHA-256 por
  prefixo) em `build/` — é a evidência que precede e acompanha qualquer troca de engine.

**Limite honesto:** o destino offsite é **configuração** (`OFFSITE_ENDPOINT` +
`OFFSITE_MEDIA_BUCKET`). Apontado para o mesmo RustFS, ele protege contra *apagamento*,
*corrupção* e *overwrite* — **não** contra a perda do host. Contra a perda do host, o destino
precisa ser outro servidor: essa é a decisão que falta do cliente (D8), não do código.

### O upgrade 1.0.1 → 1.0.2 (digest pinado no ADR-031)

Feito **depois** do backup verde, como a ordem exigia. A prova de que o dado antigo continua
legível é o inventário: **943 objetos, 0 ausentes, 0 sobrando, 0 SHA-256 divergente** entre o
antes e o depois; PUT, GET, *range*, *multipart* e checksum conferidos no 1.0.2.

### `RUSTFS_OBS_USE_STDOUT` — a decisão vem da medição, não da nota de release

No 1.0.1 a variável **silenciava** o stdout (medido: 60+ linhas JSON sem ela, 0 com ela). A
nota do 1.0.2 diz que o *honor* foi corrigido — e o **A/B medido no 1.0.2 deu 95 linhas JSON
nos dois casos**: a variável virou inócua. Ela **continua ausente** (estado explícito, e
armadilha de versão para quem subir para o 1.0.1), e o gate negativo que exige a ausência foi
**reajustado** com o número medido.
