feat: object storage (Garage) para anexos novos, sem migrar histórico #76

Merged
jhony75 merged 2 commits from feature/object-storage-garage into develop 2026-09-22 08:52:20 -03:00
Owner

Implementa o P1 "object storage para anexos" do backlog, com duas decisões de produto confirmadas com o usuário antes de implementar.

O que muda: Attachment/Image e RunAttachment ganham storage_key (nullable). Uma linha grava OU _data (BLOB) OU storage_key, nunca os dois -- a property data decide de onde ler, então nenhum ponto de leitura existente precisou mudar (attachment.data/image.data continuam funcionando idênticos em todo lugar que já chamava isso). Só o momento da escrita (services/attachments.py::decode_and_store, usado nos ~7 pontos que antes chamavam decode_image direto) decide uma vez se o anexo novo vai pro Garage ou continua BLOB.

services/object_storage.py isola o client S3 (boto3), mesmo padrão de isolamento de ai_client.py/hubspot_client.py/modules/orchestration/client.py. Opcional por design: sem as 5 env vars GARAGE_* configuradas, is_configured() é False e tudo continua BLOB automaticamente -- dev/teste não dependem do Garage estar de pé.

Duas decisões de produto confirmadas com o usuário antes de escrever código (ver ADR-0016):

  1. Sem migração automática do histórico -- anexos já existentes continuam BLOB pra sempre. Migrar dado real de produção sem plano de rollback testado contra o ambiente de verdade era risco desproporcional agora; fica como item de roadmap separado.
  2. Bucket único, compartilhado entre dev e prod (decisão de infra, temporária -- ainda não há bucket por ambiente). Isolamento garantido via prefixo de key por ENVIRONMENT: development/attachments/<uuid> vs production/attachments/<uuid>.

Correção real ao escopo esperado: a auditoria arquitetural anterior assumia que tanto Attachment quanto RunAttachment já estavam abstraídos por trás de uma property data. Isso era verdade só pra RunAttachment -- Attachment/Image usava uma Column direta. Corrigido convertendo pra property também (com @data.setter, mesmo padrão que RunAttachment já tinha), sem mudar nenhum contrato HTTP.

Verificação: 269/269 testes de backend (7 novos em test_object_storage.py: config das 5 env vars, prefixo de key por ambiente, roundtrip upload/download com fake client, fallback pra BLOB sem Garage configurado, e um teste de ponta a ponta via API confirmando que ImageOut nunca expõe storage_key e que GET /api/images/{id} devolve o binário certo nos dois casos). Migration testada upgrade→downgrade→upgrade + alembic check sem drift. Smoke test único contra o Garage real (upload + download + delete de limpeza) rodou com sucesso -- credenciais usadas só como env vars inline no shell durante o teste, nunca escritas em nenhum arquivo do repositório (confirmado por scan do diff antes do commit).

🤖 Generated with Claude Code

Implementa o P1 "object storage para anexos" do backlog, com duas decisões de produto confirmadas com o usuário antes de implementar. **O que muda**: `Attachment`/`Image` e `RunAttachment` ganham `storage_key` (nullable). Uma linha grava OU `_data` (BLOB) OU `storage_key`, nunca os dois -- a property `data` decide de onde ler, então **nenhum ponto de leitura existente precisou mudar** (`attachment.data`/`image.data` continuam funcionando idênticos em todo lugar que já chamava isso). Só o momento da escrita (`services/attachments.py::decode_and_store`, usado nos ~7 pontos que antes chamavam `decode_image` direto) decide uma vez se o anexo novo vai pro Garage ou continua BLOB. `services/object_storage.py` isola o client S3 (boto3), mesmo padrão de isolamento de `ai_client.py`/`hubspot_client.py`/`modules/orchestration/client.py`. **Opcional por design**: sem as 5 env vars `GARAGE_*` configuradas, `is_configured()` é `False` e tudo continua BLOB automaticamente -- dev/teste não dependem do Garage estar de pé. **Duas decisões de produto confirmadas com o usuário antes de escrever código** (ver [ADR-0016](docs/adr/0016-object-storage-garage.md)): 1. **Sem migração automática do histórico** -- anexos já existentes continuam BLOB pra sempre. Migrar dado real de produção sem plano de rollback testado contra o ambiente de verdade era risco desproporcional agora; fica como item de roadmap separado. 2. **Bucket único, compartilhado entre dev e prod** (decisão de infra, temporária -- ainda não há bucket por ambiente). Isolamento garantido via prefixo de key por `ENVIRONMENT`: `development/attachments/<uuid>` vs `production/attachments/<uuid>`. **Correção real ao escopo esperado**: a auditoria arquitetural anterior assumia que tanto `Attachment` quanto `RunAttachment` já estavam abstraídos por trás de uma property `data`. Isso era verdade só pra `RunAttachment` -- `Attachment`/`Image` usava uma `Column` direta. Corrigido convertendo pra property também (com `@data.setter`, mesmo padrão que `RunAttachment` já tinha), sem mudar nenhum contrato HTTP. **Verificação**: 269/269 testes de backend (7 novos em `test_object_storage.py`: config das 5 env vars, prefixo de key por ambiente, roundtrip upload/download com fake client, fallback pra BLOB sem Garage configurado, e um teste de ponta a ponta via API confirmando que `ImageOut` nunca expõe `storage_key` e que `GET /api/images/{id}` devolve o binário certo nos dois casos). Migration testada upgrade→downgrade→upgrade + `alembic check` sem drift. **Smoke test único contra o Garage real** (upload + download + delete de limpeza) rodou com sucesso -- credenciais usadas só como env vars inline no shell durante o teste, nunca escritas em nenhum arquivo do repositório (confirmado por scan do diff antes do commit). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: object storage (Garage) para anexos novos, sem migrar historico
All checks were successful
EEG QA CI / Detectar o que mudou (pull_request) Successful in 12s
EEG QA CI / Frontend (pull_request) Successful in 3m10s
EEG QA CI / Build e publicação de imagens (pull_request) Successful in 2m14s
EEG QA CI / Deploy production (pull_request) Has been skipped
EEG QA CI / Deploy development (pull_request) Has been skipped
EEG QA CI / Backend (pull_request) Successful in 1m51s
7c544d10a2
Attachment/Image e RunAttachment ganham storage_key (nullable) -- anexos
novos podem ser gravados no Garage (S3-compativel, self-hosted, disponivel
na VPS) em vez de BLOB no banco. services/object_storage.py isola o client
boto3 (mesmo padrao de ai_client.py/hubspot_client.py); sem as 5 env vars
GARAGE_* configuradas, decode_and_store continua gravando BLOB
automaticamente -- comportamento de dev/teste inalterado. Nenhum ponto de
LEITURA mudou (attachment.data continua igual em todo lugar), so a escrita
decide onde os bytes ficam.

Duas decisoes de produto confirmadas com o usuario antes de implementar:
(1) sem migracao automatica do historico -- anexos existentes continuam
BLOB pra sempre; (2) bucket compartilhado entre dev e prod (decisao de
infra temporaria), isolado via prefixo de key por ENVIRONMENT
(development/attachments/<uuid> vs production/...). Ver ADR-0016.

Migration 995241f5ea8d (storage_key nullable + images.data vira nullable).
Testado com um smoke test unico contra o Garage real (upload, download,
delete de limpeza) -- credenciais nunca commitadas, so env vars inline no
shell durante o teste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/develop' into feature/object-storage-garage
All checks were successful
EEG QA CI / Frontend (pull_request) Successful in 3m41s
EEG QA CI / Detectar o que mudou (pull_request) Successful in 12s
EEG QA CI / Backend (pull_request) Successful in 2m7s
EEG QA CI / Build e publicação de imagens (pull_request) Successful in 1m16s
EEG QA CI / Deploy production (pull_request) Has been skipped
EEG QA CI / Deploy development (pull_request) Has been skipped
3c9fe27463
# Conflicts:
#	.forgejo/workflows/ci.yml
#	backend/modules/automation/router.py
#	backend/tests/test_api_smoke.py
#	docs/BACKLOG.md
jhony75 merged commit e1990f36f5 into develop 2026-09-22 08:52:20 -03:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jhony75/eeg-qa!76
No description provided.