From b3c6caada28a1be0285bf76fa98481d652527c60 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 25 Sep 2026 11:42:39 -0300 Subject: [PATCH] fix(media): repara rota do Garage no deploy --- .../2026-09-25-media-upload-share-link.md | 1 + docs/ops/teqo-1313-deploy.md | 34 ++++++++ .../2026-09-25-media-upload-share-link.md | 82 ++++++++++++++++++ scripts/deploy-homeserver.sh | 84 +++++++++++++++++++ scripts/lib/mediaEndpoint.mjs | 69 +++++++++++++++ scripts/lib/test-affected-core.mjs | 1 + tests/unit/deployScript.unit.spec.ts | 25 ++++++ tests/unit/mediaEndpoint.unit.spec.ts | 35 ++++++++ 8 files changed, 331 insertions(+) create mode 100644 docs/changelog/2026-09-25-media-upload-share-link.md create mode 100644 docs/postmortems/2026-09-25-media-upload-share-link.md create mode 100644 scripts/lib/mediaEndpoint.mjs create mode 100644 tests/unit/mediaEndpoint.unit.spec.ts diff --git a/docs/changelog/2026-09-25-media-upload-share-link.md b/docs/changelog/2026-09-25-media-upload-share-link.md new file mode 100644 index 00000000..458958ae --- /dev/null +++ b/docs/changelog/2026-09-25-media-upload-share-link.md @@ -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. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 4fa4b79d..16bc49c6 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -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 diff --git a/docs/postmortems/2026-09-25-media-upload-share-link.md b/docs/postmortems/2026-09-25-media-upload-share-link.md new file mode 100644 index 00000000..03214981 --- /dev/null +++ b/docs/postmortems/2026-09-25-media-upload-share-link.md @@ -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. diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index 98bbf18e..1db66c11 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -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 ` (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, diff --git a/scripts/lib/mediaEndpoint.mjs b/scripts/lib/mediaEndpoint.mjs new file mode 100644 index 00000000..e7388fc1 --- /dev/null +++ b/scripts/lib/mediaEndpoint.mjs @@ -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'), + }), + ) +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index d4ade2f3..bc6e40d4 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -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', diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index 812c6b2c..c30ceaba 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -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"') diff --git a/tests/unit/mediaEndpoint.unit.spec.ts b/tests/unit/mediaEndpoint.unit.spec.ts new file mode 100644 index 00000000..4992d22d --- /dev/null +++ b/tests/unit/mediaEndpoint.unit.spec.ts @@ -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', + ) + }) +})