fix(media): repara rota do Garage no deploy

This commit is contained in:
Francisco Solla 2026-09-25 11:42:39 -03:00
parent 4be1c0923b
commit b3c6caada2
8 changed files with 331 additions and 0 deletions

View file

@ -0,0 +1 @@
**Upload de capa no admin de Links de compartilhamento ganha rota S3 validada no deploy (2026-09-25):** uploads dependiam de uma rota S3 inacessível após mudança de topologia; o deploy agora sonda o Garage pela imagem migrator, escolhe um gateway IPv4 funcional via override por ambiente e testa a rota novamente após o healthcheck, com rollback em falha. Env files e credenciais não são reescritos; sem migration ou mudança de UI. Regressões unitárias, ShareLink 25/25, e2e do link 6/6 e `pnpm gate:fast` (424 arquivos / 4698 testes) verdes. PR, CI, deploy e a confirmação em produção estão pendentes; a aprovação humana de produção continua obrigatória.

View file

@ -120,6 +120,40 @@ clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Garage e uploads de mídia
O runtime usa o endpoint S3 do arquivo de ambiente. O endpoint documentado
`http://host.docker.internal:3900` depende do gateway da rede Docker; depois de
uma alteração nas redes, o alias `host-gateway` pode resolver para um gateway
que não alcança o Garage. Nesse caso, o upload do Payload fica esperando o
`PutObject` e a interface permanece em **Enviando...**, embora o PostgreSQL e o
resto do site estejam saudáveis.
O `scripts/deploy-homeserver.sh` trata esse caminho antes das migrations: ele
sonda o endpoint a partir da imagem migrator, testa os gateways IPv4 da rede
`stack_default` quando o alias falha e gera um override operacional por
ambiente (`~/stack/docker-compose.media-production.yml` ou
`~/stack/docker-compose.media-staging.yml`). O override altera somente
`S3_ENDPOINT` nos dois serviços do ambiente selecionado; credenciais e arquivos
de ambiente não são reescritos. Depois do rollout, o mesmo endpoint é sondado de
dentro do container e uma falha executa o rollback.
O probe é fail-closed: se nenhum gateway responder, o deploy para antes de
aplicar migrations. O override é um artefato operacional do stack, não um
arquivo versionado. Para diagnosticar o estado atual:
```bash
ssh homeserver
cd ~/stack
set -a; source ~/stack/teqo-1313.env; set +a
docker network inspect -f '{{range .IPAM.Config}}{{println .Gateway}}{{end}}' stack_default
docker exec teqo-1313 node -e "fetch(process.env.S3_ENDPOINT, {signal: AbortSignal.timeout(5000)}).then(r => console.log(r.status)).catch(e => { console.error(e); process.exit(1) })"
```
O comando `docker exec` deve concluir em poucos segundos, mesmo que o Garage
responda `403` à raiz; timeout ou `ENETUNREACH` significa que o caminho do
container ainda não alcança o bucket.
## Staging (OPS103)
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes

View file

@ -0,0 +1,82 @@
# Post-mortem: upload de capa em Links de compartilhamento bloqueado pela rota S3
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
## Registro
| Campo | Valor |
| ------------------- | ----------------------------------------------------------------------------- |
| Data do post-mortem | 2026-09-25 |
| Severidade | alta (bloqueia uploads de mídia em produção) |
| Ambiente | prod (sintoma e diagnóstico read-only) + dev/worktree (reprodução e correção) |
| Issue(s) | sem Issue |
| PR do fix | pendente |
| Detectado por | humano |
## Timeline
| Momento | Data/hora | Evento |
| ------------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Início provável | não apurado | A introdução exata é não apurada. O sintoma foi reportado contra a UI da versão S29, com múltiplos destinos. |
| Detecção | 2026-09-25, hora não apurada | O humano relatou que o envio da capa permanecia em "Enviando..." até desistir no admin de Links de compartilhamento. |
| Diagnóstico | 2026-09-25 | Diagnóstico read-only em produção associou o bloqueio à rota S3 do container. |
| Correção mergeada | pendente | Correção implementada no worktree `fix/13`; PR e CI pendentes. |
| Deploy | pendente | O merge dispara o deploy; staging é automático e produção depende de aprovação humana. |
| Verificado em prod | pendente — aprovação humana | CI, PR, deploy e confirmação do comportamento em produção estão pendentes. |
## O bug
Em produção, no admin de Links de compartilhamento, o envio de uma capa permanecia em **Enviando...** até a desistência. O relato ocorreu na versão S29, com múltiplos destinos.
O incidente não foi reproduzido com o storage local do worktree `fix/13`, em `http://localhost:3297`. O envio da capa funcionou tanto na criação de um link quanto na edição de um link com duas linhas no array `destinations`: `Imagem > Criar Novo`, arquivo, alt e Salvar resultaram em `POST /api/media 201` e upload concluído. O formulário e o array não causaram o incidente.
## Causa-raiz
O `PutObject` S3 aguardava uma rota de rede inacessível. Os logs do container em produção registravam `connect ETIMEDOUT 10.0.0.1:3900`; `host.docker.internal` resolvia para `10.0.0.1`, enquanto o gateway IPv4 `10.0.11.1` da rede `stack_default` respondia de imediato ao Garage. A topologia observada tornou o alias `host-gateway` uma rota não confiável até o Garage. A alteração que introduziu essa mudança e a origem exata do incidente são não apuradas. A configuração efetiva do UFW não foi inspecionada, portanto este documento não atribui a causa ao firewall.
`src/payload.config.ts:184-207` repassa `S3_ENDPOINT` ao plugin S3. `src/collections/ShareLink.ts:257-265` define apenas o campo de upload e não participa do POST de `media`. Não há base para atribuir o incidente ao S29; ele é apenas a UI contra a qual o sintoma foi reportado.
### 5 whys
1. Por que a tela fica em "Enviando..."? Porque `POST /api/media` não conclui enquanto aguarda o `PutObject`.
2. Por que o POST não conclui? Porque a operação S3 expira ao tentar conectar em `10.0.0.1:3900`.
3. Por que o container tenta esse endereço? Porque `host.docker.internal` resolve para `10.0.0.1` em produção.
4. Por que essa rota falha? Porque o alias `host-gateway` deixou de alcançar o Garage após a mudança de topologia; o evento exato não foi apurado.
5. Por que o deploy não detectou a indisponibilidade? Porque o caminho S3 não tinha um probe próprio no fluxo de deploy.
## Correção
`scripts/deploy-homeserver.sh` agora testa o endpoint S3 a partir da imagem migrator já construída, antes das migrations. Se `host.docker.internal` falhar, o script testa os gateways IPv4 de `stack_default`; o primeiro gateway acessível gera, por `scripts/lib/mediaEndpoint.mjs`, um override de Compose específico do ambiente. O override altera `S3_ENDPOINT` somente no runner e no serviço de migrations, é validado com `docker compose ... config --quiet` e passa a valer via `COMPOSE_FILE`. Arquivos de ambiente e credenciais não são reescritos.
Depois do healthcheck, o endpoint é sondado de dentro do container em execução; uma falha aciona o rollback. Se nenhum gateway responder, o deploy para antes das migrations. O procedimento também ficou registrado em `docs/ops/teqo-1313-deploy.md`. Nenhuma migration foi criada; o campo de `ShareLink` e a UI não foram alterados.
## Verificação
- Teste de regressão: `tests/unit/mediaEndpoint.unit.spec.ts`, 3/3; no HEAD pré-fix, o novo import de `scripts/lib/mediaEndpoint.mjs` não existia, e o spec passa com a correção.
- Regressão do deploy: `tests/unit/deployScript.unit.spec.ts`, "repairs a broken S3 host gateway before migration and verifies the live route"; falha no estado pré-fix e passa com a correção.
- Suíte: `pnpm gate:fast` verde, 424 arquivos e 4698 testes; integração de ShareLink 25/25; `frontendShareLink` e2e 6/6; `pnpm format:check`, `pnpm check:cycles`, `pnpm knip` e `bash -n` verdes.
- CI: pendente; o PR ainda não foi criado.
- Deploy: pendente de PR, CI e merge; a produção ainda depende de aprovação humana.
- verificado em prod: pendente — aprovação humana.
## Prevenção
| Estratégia | Custo | Estado |
| ------------------------------------------------------------------------------------- | ------ | ----------------------------------------- |
| Probe do S3 na imagem migrator e fallback dinâmico para um gateway IPv4 acessível | barata | implementada neste worktree |
| Probe do S3 no container em execução após o healthcheck, com rollback em falha | barata | implementada neste worktree |
| Regressões unitárias do helper e do script, mais diagnóstico e recuperação no runbook | barata | implementada neste worktree |
| Observabilidade contínua do S3 e alertas de falha ou latência | cara | documentada, não implementada neste fluxo |
| Upload sintético de mídia para detectar perda da rota antes do uso humano | cara | documentada, não implementada neste fluxo |
**Estratégia implementada:** o deploy passa a validar a rota antes das migrations, ajustar apenas o endpoint dos serviços do ambiente selecionado e repetir a validação no container em execução. Os testes de regressão fixam a ordem e o fallback, e o runbook registra o diagnóstico.
**Estratégia documentada (cara):** observabilidade contínua e alertas do S3, além de upload sintético de mídia.
## Lições
- O storage local prova o caminho do formulário, mas não exercita a rota S3 de produção.
- O campo exibido no admin apontava para `ShareLink`; a falha real ocorreu no POST de `media`. O diagnóstico precisou seguir a requisição até o `PutObject`.
- A versão S29 é o contexto do relato, não uma origem comprovada.
- Health do site e alcance do Garage são dependências diferentes. O deploy precisa testar a segunda antes de declarar o rollout saudável.
- Sem inspeção do UFW, a causa fica limitada à rota observada; atribuir o incidente ao firewall seria especulativo.

View file

@ -63,6 +63,87 @@ fatal() {
exit 1
}
probe_media_endpoint() {
local image="$1"
local endpoint="$2"
local host="${3:-}"
local -a args=(--rm --network stack_default)
if [ -n "$host" ]; then
args+=(--add-host "host.docker.internal:$host")
fi
docker run "${args[@]}" \
--env "S3_PROBE_ENDPOINT=$endpoint" \
--entrypoint node \
"$image" \
-e 'fetch(process.env.S3_PROBE_ENDPOINT, { signal: AbortSignal.timeout(5000) }).then(() => process.exit(0)).catch(() => process.exit(1))' \
>/dev/null 2>&1
}
media_gateways() {
docker network inspect -f '{{range .IPAM.Config}}{{println .Gateway}}{{end}}' stack_default 2>/dev/null
}
ensure_media_endpoint() {
local image="$TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA"
local override="$STACK_DIR/docker-compose.media-$TEQO_ENV.yml"
if [ -z "${S3_BUCKET:-}" ] || [ -z "${S3_ENDPOINT:-}" ] || [ -z "${S3_ACCESS_KEY_ID:-}" ] || [ -z "${S3_SECRET_ACCESS_KEY:-}" ]; then
rm -f "$override"
unset COMPOSE_FILE
return 0
fi
if ! node "$WORKSPACE_DIR/scripts/lib/mediaEndpoint.mjs" is-host-gateway --endpoint "$S3_ENDPOINT"; then
if probe_media_endpoint "$image" "$S3_ENDPOINT" ""; then
rm -f "$override"
unset COMPOSE_FILE
return 0
fi
fatal "configured S3 endpoint is unreachable from the Docker network"
fi
if probe_media_endpoint "$image" "$S3_ENDPOINT" "host-gateway"; then
rm -f "$override"
unset COMPOSE_FILE
return 0
fi
local gateway
while IFS= read -r gateway; do
if [[ ! "$gateway" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
continue
fi
if probe_media_endpoint "$image" "$S3_ENDPOINT" "$gateway"; then
if ! node "$WORKSPACE_DIR/scripts/lib/mediaEndpoint.mjs" \
--container "$TEQO_CONTAINER" \
--migrate-service "$TEQO_MIGRATE_SERVICE" \
--endpoint "$S3_ENDPOINT" \
--gateway "$gateway" >"$override"; then
rm -f "$override"
fatal "failed to render media endpoint override"
fi
if ! docker compose -f "$STACK_DIR/docker-compose.yml" -f "$override" config --quiet; then
rm -f "$override"
fatal "media endpoint override is invalid"
fi
export COMPOSE_FILE="$STACK_DIR/docker-compose.yml:$override"
say "repairing S3 route for $TEQO_CONTAINER with the reachable stack gateway"
return 0
fi
done < <(media_gateways)
fatal "no reachable IPv4 gateway for the configured S3 endpoint"
}
smoke_media_endpoint() {
if [ -z "${S3_BUCKET:-}" ] || [ -z "${S3_ENDPOINT:-}" ] || [ -z "${S3_ACCESS_KEY_ID:-}" ] || [ -z "${S3_SECRET_ACCESS_KEY:-}" ]; then
return 0
fi
docker exec "$TEQO_CONTAINER" node -e \
'fetch(process.env.S3_ENDPOINT, { signal: AbortSignal.timeout(5000) }).then(() => process.exit(0)).catch(() => process.exit(1))' \
>/dev/null 2>&1 || rollback "S3 endpoint missing or unreachable from $TEQO_CONTAINER"
}
# --- environment map (OPS103) -------------------------------------------
# Production values are the pre-OPS103 literals: the canonical invocation
# `bash scripts/deploy-homeserver.sh <sha>` (no TEQO_ENV) behaves exactly as
@ -207,6 +288,7 @@ docker push "$TEQO_REGISTRY/$TEQO_IMAGE_REPO-migrator:$SHA"
# --- compose swap (backup first; failures from here roll back) ----------
compose="$STACK_DIR/docker-compose.yml"
ensure_media_endpoint
backup="$compose.pre-$SHA"
cp "$compose" "$backup"
# Image lines: the env-specific repo names cannot touch the other
@ -276,6 +358,8 @@ for _ in $(seq 1 30); do
done
[ "$health" = "healthy" ] || rollback "container not healthy after 300s (status: $health)"
smoke_media_endpoint
# --- feature env audit (non-fatal) --------------------------------------
# A missing feature key never fails the boot: the capability degrades
# silently (AI theme search/titles/transcription, forgot-password e-mail,

View file

@ -0,0 +1,69 @@
import { isIPv4 } from 'node:net'
const HOST_GATEWAY = 'host.docker.internal'
const SERVICE_NAME = /^[A-Za-z0-9_.-]+$/
const parseEndpoint = (endpoint) => {
try {
return new URL(endpoint)
} catch {
return null
}
}
export const isHostGatewayEndpoint = (endpoint) => {
const url = parseEndpoint(endpoint)
return url?.hostname === HOST_GATEWAY
}
export const replaceEndpointHost = (endpoint, host) => {
const url = parseEndpoint(endpoint)
if (!url || !isIPv4(host)) throw new Error('Invalid media endpoint host')
const hadExplicitPath = url.pathname !== '/'
url.hostname = host
const resolved = url.toString()
return hadExplicitPath || !resolved.endsWith('/') ? resolved : resolved.slice(0, -1)
}
export const renderMediaEndpointOverride = ({ container, migrateService, endpoint, gateway }) => {
if (
!SERVICE_NAME.test(container) ||
!SERVICE_NAME.test(migrateService) ||
container === migrateService ||
!isHostGatewayEndpoint(endpoint) ||
!isIPv4(gateway)
) {
throw new Error('Invalid media endpoint override')
}
const resolvedEndpoint = replaceEndpointHost(endpoint, gateway)
return [
'services:',
` ${container}:`,
' environment:',
` S3_ENDPOINT: ${JSON.stringify(resolvedEndpoint)}`,
` ${migrateService}:`,
' environment:',
` S3_ENDPOINT: ${JSON.stringify(resolvedEndpoint)}`,
'',
].join('\n')
}
if (process.argv[1]?.endsWith('/mediaEndpoint.mjs')) {
const args = process.argv.slice(2)
const value = (name) => {
const index = args.indexOf(name)
return index === -1 ? '' : args[index + 1]
}
if (args[0] === 'is-host-gateway') {
process.exit(isHostGatewayEndpoint(value('--endpoint')) ? 0 : 1)
}
process.stdout.write(
renderMediaEndpointOverride({
container: value('--container'),
migrateService: value('--migrate-service'),
endpoint: value('--endpoint'),
gateway: value('--gateway'),
}),
)
}

View file

@ -83,6 +83,7 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/issues-panel.mjs',
'scripts/lib/markdown-ansi.mjs',
'scripts/lib/mediaBinaries.mjs',
'scripts/lib/mediaEndpoint.mjs',
'scripts/lib/plansOnlyClosesGuard.mjs',
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',

View file

@ -147,6 +147,31 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => {
expect(callIndex).toBeLessThan(smokeIndex)
})
it('repairs a broken S3 host gateway before migration and verifies the live route', () => {
for (const fragment of [
'ensure_media_endpoint',
'probe_media_endpoint',
'docker network inspect',
'--add-host "host.docker.internal:$host"',
'docker compose -f "$STACK_DIR/docker-compose.yml" -f "$override" config --quiet',
'docker-compose.media-$TEQO_ENV.yml',
'export COMPOSE_FILE=',
'smoke_media_endpoint',
]) {
expect(script, fragment).toContain(fragment)
}
const ensureCall = script.indexOf('ensure_media_endpoint\nbackup=')
const migrateCall = script.indexOf('docker compose --profile maintenance run --rm')
const healthCall = script.indexOf('[ "$health" = "healthy" ]')
const smokeCall = script.indexOf('\nsmoke_media_endpoint\n')
expect(ensureCall).toBeGreaterThan(-1)
expect(ensureCall).toBeLessThan(migrateCall)
expect(smokeCall).toBeGreaterThan(healthCall)
expect(script).not.toContain('10.0.11.1')
expect(script).not.toContain('10.0.0.1')
})
it('never echoes secret values (no set -x, passwords only via stdin/secrets)', () => {
expect(script).not.toMatch(/^set -x\b/m)
expect(script).not.toContain('echo "$DATABASE_URL"')

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
isHostGatewayEndpoint,
renderMediaEndpointOverride,
replaceEndpointHost,
} from '../../scripts/lib/mediaEndpoint.mjs'
describe('media endpoint deploy fallback', () => {
it('recognizes only the documented host gateway endpoint for automatic repair', () => {
expect(isHostGatewayEndpoint('http://host.docker.internal:3900')).toBe(true)
expect(isHostGatewayEndpoint('http://10.0.11.1:3900')).toBe(false)
expect(isHostGatewayEndpoint('not-a-url')).toBe(false)
})
it('renders an override for both services of only the selected environment', () => {
const override = renderMediaEndpointOverride({
container: 'teqo-1313',
migrateService: 'teqo-1313-migrate',
endpoint: 'http://host.docker.internal:3900',
gateway: '10.0.11.1',
})
expect(override).toContain('teqo-1313:')
expect(override).toContain('teqo-1313-migrate:')
expect(override).toContain('S3_ENDPOINT: "http://10.0.11.1:3900"')
expect(override).not.toContain('teqo-staging')
})
it('preserves the endpoint port and path when replacing the host', () => {
expect(replaceEndpointHost('http://host.docker.internal:3900/media', '10.0.11.1')).toBe(
'http://10.0.11.1:3900/media',
)
})
})