diff --git a/.gitignore b/.gitignore index 0dcc654..807f90b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ docs/tickets/*/*/* .playwright-mcp/ # O estado interno do harness: a trava e as aprovações. .harness/ -docs/research/ \ No newline at end of file +docs/research/ +# O .env do Compose, com o slot do checkout (docs/lld.md, seção 6, "Ambiente local por slot"). +/.env \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 5295baf..149447a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Tudo roda dentro dos contêineres do Docker Compose: instalação de dependênci O ambiente de desenvolvimento usa o `compose.dev.yaml`, na raiz, com seis serviços: -| Serviço | O que é | Endereço na máquina | Endereço entre contêineres | +| Serviço | O que é | Endereço na máquina (slot 0) | Endereço entre contêineres | | --- | --- | --- | --- | | `web` | Frontend em Next.js | `http://localhost:3000` | `http://web:3000` | | `api` | API em NestJS, com as rotas sob `/v1` | `http://localhost:3001` | `http://api:3000` | @@ -46,6 +46,8 @@ O ambiente de desenvolvimento usa o `compose.dev.yaml`, na raiz, com seis servi | `mailpit` | Captura os e-mails enviados e os mostra numa tela | `http://localhost:8025` | SMTP em `mailpit:1025` e API HTTP em `http://mailpit:8025` | | `playwright` | Navegadores dos testes da web | Não é publicado | Não recebe conexões | +A tabela é a do slot 0, o de um checkout sem `.env` na raiz. As portas e os endereços de um slot de 1 a 3 estão no `docs/lld.md`, seção 6, "Ambiente local por slot". + Os contêineres `web` e `api` preparam o próprio ambiente ao subir e depois ficam parados, sem iniciar a aplicação. O `playwright` também fica parado. Os comandos rodam neles com `docker compose -f compose.dev.yaml exec `, e os de cada projeto, inclusive os testes no `playwright`, estão no `AGENTS.md` dele. O `web` e o `playwright` montam a pasta da web em `/app`, e o `api`, a da API. O `api` monta também o repositório em `/repo`, só para leitura e sem o `.git`, onde roda o Node do harness (o "Comando do Node" dos valores da skill de plano, abaixo). diff --git a/README.md b/README.md index 072655d..cc89e6d 100644 --- a/README.md +++ b/README.md @@ -82,4 +82,6 @@ docker compose -f compose.dev.yaml exec api pnpm start:dev | API | | | E-mails capturados | | +São os endereços do slot 0, o de um checkout sem `.env` na raiz. Vários checkouts ao mesmo tempo usam um slot cada um, com portas próprias: o contrato do `.env` está no [`docs/lld.md`](./docs/lld.md), seção 6, "Ambiente local por slot". + Os detalhes do ambiente e os demais comandos estão no [`AGENTS.md`](./AGENTS.md). diff --git a/apps/api/.env.example b/apps/api/.env.example index 3915ce9..0ce0dd1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,7 +18,8 @@ MAIL_FROM="Gerenciador de Arquivos " # SMTP_IDLE_TIMEOUT_MS=60000 # Origem do frontend, usada nos links dos e-mails. É o endereço que a pessoa abre no navegador, -# e por isso é o da máquina, e não o nome do serviço do Compose. +# e por isso é o da máquina, e não o nome do serviço do Compose. Num slot, o Compose passa o do slot +# nesta variável, e o ambiente do processo vale mais do que esta linha (docs/lld.md, seção 6, "Ambiente local por slot"). WEB_ORIGIN=http://localhost:3000 # Validade do link de verificação de e-mail, em segundos. Sem a variável, vale o padrão de produção. @@ -53,7 +54,8 @@ LOGIN_RATE_LIMIT_PER_IP=100000 EMAIL_REQUEST_RATE_LIMIT_PER_IP=100000 # Storage de arquivos. O emulador (`storage`, no Compose) é falado por STORAGE_EMULATOR_HOST, o -# nome do serviço. O navegador só alcança o endereço que o Compose publica, STORAGE_PUBLIC_URL. +# nome do serviço. O navegador só alcança o endereço que o Compose publica, STORAGE_PUBLIC_URL. Num slot, +# o Compose passa os dois endereços do slot, que valem mais do que estas linhas. # A chave que assina as URLs (STORAGE_SIGNER_PRIVATE_KEY) é gerada na primeira subida e gravada # direto no `.env`. Em produção, nenhuma das quatro últimas existe (docs/lld.md, seção 4.6). GCS_BUCKET=app-files diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 186bdd3..ae26f50 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -8,7 +8,7 @@ docker compose -f compose.dev.yaml exec api | Comando | O que faz | | --- | --- | -| `pnpm start:dev` | Sobe a API em modo watch (`http://localhost:3001` na máquina) | +| `pnpm start:dev` | Sobe a API em modo watch (`http://localhost:3001` na máquina, no slot 0) | | `pnpm test` | Suíte de unidade (`*.spec.ts`) | | `pnpm test:int` | Suíte de integração (`*.int-spec.ts`) | | `pnpm test:e2e` | Suíte de ponta a ponta (`*.e2e-spec.ts`) | diff --git a/apps/api/test/auth-password-recovery.e2e-spec.ts b/apps/api/test/auth-password-recovery.e2e-spec.ts index 76942de..710017b 100644 --- a/apps/api/test/auth-password-recovery.e2e-spec.ts +++ b/apps/api/test/auth-password-recovery.e2e-spec.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { App } from 'supertest/types.js'; +import { appConfig } from '../src/config/app.config.js'; import { authRoutes, PASSWORD, @@ -38,7 +39,7 @@ describe('Recuperação de senha', () => { expect(mail.subject).toBe(RESET_SUBJECT); expect(mail.to).toEqual([email]); expect(mail.text).toContain( - 'http://localhost:3000/redefinir-senha?token=', + `${app.get(appConfig.KEY).webOrigin}/redefinir-senha?token=`, ); expect(mail.text).toContain('1 hora'); expect(resetToken(mail)).toMatch(/^[\w-]{43}$/); @@ -137,7 +138,9 @@ describe('Recuperação de senha', () => { expect(mail.subject).toBe(PASSWORD_CHANGED_SUBJECT); expect(mail.to).toEqual([email]); expect(mail.text).not.toContain('token='); - expect(mail.text).toContain('http://localhost:3000/esqueci-minha-senha'); + expect(mail.text).toContain( + `${app.get(appConfig.KEY).webOrigin}/esqueci-minha-senha`, + ); }); it('aceita o link uma vez só: o segundo uso devolve invalid_token e não troca a senha de novo', async () => { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e3f736d..143aecd 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -18,7 +18,7 @@ docker compose -f compose.dev.yaml exec web | Comando | O que faz | | --- | --- | -| `pnpm dev --hostname 0.0.0.0` | Sobe o servidor de desenvolvimento (`http://localhost:3000`) | +| `pnpm dev --hostname 0.0.0.0` | Sobe o servidor de desenvolvimento (`http://localhost:3000` no slot 0) | | `pnpm lint` | ESLint com as regras do `eslint-config-next` | | `pnpm exec tsc --noEmit` | Checagem de tipos | | `pnpm exec next typegen` | Gera os tipos globais das rotas (`PageProps`, `LayoutProps`). O `pnpm dev` e o `pnpm build` já os geram; num clone novo, rode antes do `tsc`. | @@ -156,7 +156,7 @@ docker compose -f compose.dev.yaml exec playwright - **Itens na Pasta raiz:** o teste do próprio envio sobe o arquivo pelo botão "Enviar". Para a lista com vários arquivos, com nome, tipo, tamanho e data escolhidos, `seedRootFiles()`, de `e2e/support/items.ts`, grava arquivos ativos direto no banco da API, pela `DATABASE_URL` do serviço `playwright`. A Cota cheia, que nenhuma tela produz, é gravada do mesmo jeito, por `fillQuota()` (`e2e/support/quota.ts`). Pelo mesmo caminho, `usedBytesOf()`, do mesmo arquivo, lê a Cota usada, que nenhuma tela mostra até a funcionalidade dela. O Usuário já tem de ter entrado, porque a raiz nasce na primeira listagem; depois de gravar, recarregue a página. - **Token de acesso expirado:** o teste não espera os 15 minutos. Ele apaga o cookie do token de acesso, que é o que o navegador faz quando o token expira, e deixa o de renovação. - **E-mails:** `e2e/support/mailpit.ts` lê os e-mails pelo Mailpit. `uniqueEmail()` cria um destinatário que nenhum outro teste usa, `waitForMailTo()` espera o e-mail chegar e `extractLink()` tira dele o link de um caminho. Nenhum teste apaga a caixa do Mailpit, que também serve ao desenvolvimento. -- **Exceção, prova de infraestrutura:** `e2e/storage-emulator.spec.ts` prova o emulador de storage, e não uma tela. A página só serve de origem para o navegador falar com o emulador. O teste cria a sessão por `POST /uploads`, confere o tamanho pela conclusão e pede a URL de leitura a `GET /items/{id}/download-url`, com o token da Sessão (`e2e/support/storage.ts`). O Chromium resolve `localhost` para o serviço `storage` (`playwright.config.ts`), para o endereço público do emulador (`localhost:4443`) valer no contêiner `playwright`. +- **Exceção, prova de infraestrutura:** `e2e/storage-emulator.spec.ts` prova o emulador de storage, e não uma tela. A página só serve de origem para o navegador falar com o emulador. O teste cria a sessão por `POST /uploads`, confere o tamanho pela conclusão e pede a URL de leitura a `GET /items/{id}/download-url`, com o token da Sessão (`e2e/support/storage.ts`). O Chromium resolve `localhost` para o serviço `storage` (`playwright.config.ts`), para o endereço público do emulador (`localhost:4443` no slot 0) valer no contêiner `playwright`. Num slot, o teste lê o host dele de `STORAGE_PUBLIC_URL`, pelo `storagePublicHost()` de `e2e/support/storage.ts`. - **Envio em chunks:** o teste que envia um arquivo maior que um chunk, ou que falha, pausa ou cancela um chunk, chama antes `interceptStorageSession(page)`, de `e2e/support/storage-session.ts` (o porquê está no `docs/lld.md`, seção 4.6). `failOnce` e `hold` acham o chunk pelo tamanho total do arquivo, então arquivos enviados juntos têm tamanhos diferentes. O motor de envio, sem React, é provado por unidade (`lib/uploads/engine.spec.ts`). - **Seletores:** pelo papel e pelo nome acessível (`getByRole`, `getByLabel`), que é como o Usuário acha o elemento. `data-testid` fica para o que não tem papel nem rótulo. - **Depois de navegar por um link, prefira `getByRole`.** O Next.js mantém a tela anterior escondida na página, e `getByLabel` acha também os campos dela. `getByRole` ignora o que está escondido. diff --git a/apps/web/e2e/download.spec.ts b/apps/web/e2e/download.spec.ts index 721ad1e..8f3bca6 100644 --- a/apps/web/e2e/download.spec.ts +++ b/apps/web/e2e/download.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import { signIn } from "./support/account"; import { uniqueEmail } from "./support/mailpit"; import { accessTokenOf } from "./support/session"; -import { completeUpload, createUpload } from "./support/storage"; +import { completeUpload, createUpload, storagePublicHost } from "./support/storage"; /** * Deixa na lista um arquivo ativo de nome e conteúdo conhecidos: cria o envio pela API, manda os @@ -90,7 +90,7 @@ test("o HTML da lista não traz URL assinada do storage, só o caminho da web", }) => { await signIn(page, uniqueEmail()); const itemId = await seedFile(page, context, "relatorio.txt", "ola!!"); - const forbidden = ["localhost:4443", "X-Goog-Signature", "response-content-disposition"]; + const forbidden = [storagePublicHost(), "X-Goog-Signature", "response-content-disposition"]; const served = await context.request.get("/"); const servedHtml = await served.text(); diff --git a/apps/web/e2e/storage-emulator.spec.ts b/apps/web/e2e/storage-emulator.spec.ts index 18f395d..f28b128 100644 --- a/apps/web/e2e/storage-emulator.spec.ts +++ b/apps/web/e2e/storage-emulator.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { signIn } from "./support/account"; import { uniqueEmail } from "./support/mailpit"; import { accessTokenOf } from "./support/session"; -import { completeUpload, createUpload, downloadUrlOf } from "./support/storage"; +import { completeUpload, createUpload, downloadUrlOf, storagePublicHost } from "./support/storage"; const CHUNK = 256 * 1024; @@ -99,8 +99,8 @@ test.describe("Emulador de storage", () => { chunks.push(chunk as Buffer); } - expect(new URL(uploadUrl).host).toBe("localhost:4443"); - expect(new URL(readUrl).host).toBe("localhost:4443"); + expect(new URL(uploadUrl).host).toBe(storagePublicHost()); + expect(new URL(readUrl).host).toBe(storagePublicHost()); expect(status).toBe(200); expect(download.suggestedFilename()).toBe("relatório.pdf"); expect(Buffer.concat(chunks).toString()).toBe(content); diff --git a/apps/web/e2e/support/storage.ts b/apps/web/e2e/support/storage.ts index 4aca3f2..48a7ff6 100644 --- a/apps/web/e2e/support/storage.ts +++ b/apps/web/e2e/support/storage.ts @@ -5,6 +5,14 @@ import { requiredEnv } from "./env"; * envio por `POST /uploads`, o mesmo caminho da tela, conferem o tamanho pela conclusão e pedem a URL de leitura. */ +/** + * O host do endereço público do emulador de storage, o que as URLs da API trazem: o do slot do ambiente + * (`STORAGE_PUBLIC_URL`, que o Compose passa quando há um `.env` de slot) ou o do slot 0. + */ +export function storagePublicHost(): string { + return new URL(process.env.STORAGE_PUBLIC_URL ?? "http://localhost:4443").host; +} + async function api( path: string, accessToken: string, diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index f36cccd..4e71662 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -12,9 +12,9 @@ import { requiredEnv } from "./e2e/support/env"; const chromium = { ...devices["Desktop Chrome"], launchOptions: { - // O endereço público do emulador de storage é `localhost:4443`, e as URLs que a API - // entrega valem para o navegador de quem desenvolve. Neste contêiner, `localhost` não é - // o storage: o Chromium o resolve para o serviço `storage` (docs/lld.md, seção 4.6). + // O endereço público do emulador de storage é `localhost:4443` no slot 0 (a porta segue o slot), e as + // URLs que a API entrega valem para o navegador de quem desenvolve. Neste contêiner, `localhost` não + // é o storage: o Chromium o resolve para o serviço `storage`, mantendo a porta (docs/lld.md, seção 4.6). args: ["--host-resolver-rules=MAP localhost storage"], }, }; diff --git a/compose.dev.yaml b/compose.dev.yaml index 1e0363e..fc02c84 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -6,7 +6,9 @@ services: # Faz o contêiner parado encerrar na hora no `down`. init: true ports: - - "3000:3000" + # A porta da máquina segue o slot (docs/lld.md, seção 6, "Ambiente local por slot"); sem o `.env`, + # é a de hoje. + - "${WEB_PORT:-3000}:3000" volumes: - ./apps/web:/app environment: @@ -32,17 +34,28 @@ services: dockerfile: Dockerfile.dev init: true ports: - # Na máquina a API fica na 3001; entre contêineres, em http://api:3000. - - "3001:3000" + # Na máquina a API fica na 3001 do slot 0; entre contêineres, em http://api:3000. + - "${API_PORT:-3001}:3000" + environment: + # Os endereços que seguem o slot (docs/lld.md, seção 6, "Ambiente local por slot"). Sem valor: o + # Compose os lê do `.env`, e sem o `.env` eles não existem aqui, e a API usa o `apps/api/.env`. O + # ambiente do processo vale mais do que o `.env` da API. + WEB_ORIGIN: + STORAGE_PUBLIC_URL: + STORAGE_EMULATOR_HOST: volumes: - ./apps/api:/app # O repositório inteiro, só para leitura, para o Node do harness (scripts/node-in-api.sh) ler # os scripts, os arquivos de aceite e os relatórios das apps sem cópia. As escritas continuam # em /app. - .:/repo:ro - # O .git do repositório fica coberto por uma pasta vazia: nada no contêiner usa o Git, e assim o - # histórico, os hooks e a configuração dele ficam fora do alcance do que roda aqui. - - type: tmpfs + # O .git do repositório fica coberto: nada no contêiner usa o Git, e assim o histórico, os hooks e + # a configuração dele ficam fora do alcance do que roda aqui. No checkout principal e na CI, o .git + # é uma pasta, e a máscara é uma pasta vazia (tmpfs). Num worktree, o .git é um arquivo, onde o tmpfs + # não monta: o `.env` do worktree traz GIT_MASK_TYPE=bind e GIT_MASK_SOURCE=/dev/null, que o cobre + # com um arquivo vazio (docs/lld.md, seção 6, "Máscara do `.git`"). + - type: ${GIT_MASK_TYPE:-tmpfs} + source: ${GIT_MASK_SOURCE:-} target: /repo/.git read_only: true depends_on: @@ -83,6 +96,9 @@ services: DATABASE_URL: postgresql://app:app@postgres:5432/app # A API, para os testes criarem o envio e pedirem a URL de leitura (POST /uploads e GET /items/{id}/download-url). API_URL: http://api:3000 + # O endereço público do storage do slot, que os testes conferem nas URLs. Sem valor: o Compose a + # lê do `.env`, e sem o `.env` ela não existe, e os testes usam o do slot 0. + STORAGE_PUBLIC_URL: depends_on: # Usa a `node_modules` que o contêiner `web` instala. web: @@ -116,7 +132,7 @@ services: image: axllent/mailpit:v1.30 ports: # Tela e API HTTP dos e-mails capturados. O SMTP (1025) só é usado entre contêineres. - - "8025:8025" + - "${MAILPIT_PORT:-8025}:8025" storage: # Emulador do Cloud Storage. A versão é fixa: o comportamento do emulador está medido @@ -124,17 +140,18 @@ services: image: fsouza/fake-gcs-server:1.56.1 init: true ports: - # É a porta que o navegador de quem desenvolve alcança (STORAGE_PUBLIC_URL). - - "4443:4443" + # É a porta que o navegador de quem desenvolve alcança (STORAGE_PUBLIC_URL). O emulador escuta + # nela também dentro do contêiner, para o navegador dos testes, que só troca o host, chegar a ele. + - "${STORAGE_PORT:-4443}:${STORAGE_PORT:-4443}" # O emulador monta o endereço da sessão e serve a URL assinada pelo `Host` da requisição, # e só atende a URL assinada quando ele é o `-public-host`. O bucket nasce da pasta # /data/app-files, que o entrypoint cria antes de iniciar o emulador. entrypoint: - sh - -c - - mkdir -p /data/app-files && exec /bin/fake-gcs-server -data /data -scheme http -backend memory -port 4443 -public-host localhost:4443 + - mkdir -p /data/app-files && exec /bin/fake-gcs-server -data /data -scheme http -backend memory -port ${STORAGE_PORT:-4443} -public-host localhost:${STORAGE_PORT:-4443} healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4443/storage/v1/b/app-files"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${STORAGE_PORT:-4443}/storage/v1/b/app-files"] interval: 5s timeout: 3s retries: 5 diff --git a/docs/lld.md b/docs/lld.md index ee46db2..fe8303d 100644 --- a/docs/lld.md +++ b/docs/lld.md @@ -489,10 +489,10 @@ Uma falha do fornecedor (rede, permissão, bucket inexistente) sai como `Storage **Endereço público e assinatura local** -- **Dois endereços do emulador:** a API fala com ele por `STORAGE_EMULATOR_HOST` (`http://storage:4443`, o nome do serviço do Compose). O navegador não alcança esse host, e alcança `http://localhost:4443`, a porta que o Compose publica. Esse é o `STORAGE_PUBLIC_URL`, que só existe no ambiente local. -- **Endereço da sessão:** o emulador monta o endereço da sessão com o `Host` da requisição que recebeu, então ele volta com `storage:4443`. Com `STORAGE_PUBLIC_URL` definido, a implementação troca a origem do endereço por ela antes de devolvê-lo. Sem a variável (staging e produção), o endereço sai como veio do Cloud Storage. +- **Dois endereços do emulador:** a API fala com ele por `STORAGE_EMULATOR_HOST` (`http://storage:4443` no slot 0, o nome do serviço do Compose). O navegador não alcança esse host, e alcança `http://localhost:4443`, a porta que o Compose publica. Esse é o `STORAGE_PUBLIC_URL`, que só existe no ambiente local. Num slot, o emulador escuta e se anuncia na porta do slot, e os dois endereços a seguem (seção 6, "Ambiente local por slot"). +- **Endereço da sessão:** o emulador monta o endereço da sessão com o `Host` da requisição que recebeu, então ele volta com o host do serviço (`storage:4443` no slot 0). Com `STORAGE_PUBLIC_URL` definido, a implementação troca a origem do endereço por ela antes de devolvê-lo. Sem a variável (staging e produção), o endereço sai como veio do Cloud Storage. - **URL assinada:** a implementação passa a origem de `STORAGE_PUBLIC_URL` como `host` da assinatura, e a URL já sai com o endereço que o navegador alcança. O emulador só serve a URL assinada (`//`) quando o `Host` da requisição é igual ao `-public-host` dele, que o Compose define com o mesmo host e porta do `STORAGE_PUBLIC_URL`. -- **Navegador dos testes:** o navegador do serviço `playwright` está noutro contêiner, onde `localhost` não é o storage. A configuração do Playwright o faz resolver `localhost` para o serviço `storage` (`--host-resolver-rules`), e as páginas continuam na web (`http://web:3000`, ou o build servido pelo `scripts/verify.sh`). +- **Navegador dos testes:** o navegador do serviço `playwright` está noutro contêiner, onde `localhost` não é o storage. A configuração do Playwright o faz resolver `localhost` para o serviço `storage` (`--host-resolver-rules`), e as páginas continuam na web (`http://web:3000`, ou o build servido pelo `scripts/verify.sh`). O `--host-resolver-rules` troca só o host e mantém a porta do endereço público, que por isso é também a porta em que o emulador escuta dentro do contêiner. - **Assinatura sem segredo real:** o SDK precisa de uma chave privada para assinar, mesmo apontado para o emulador. Em produção, a assinatura usa a identidade do serviço no Cloud Run, sem chave no ambiente. Localmente, o `scripts/setup-env.mjs` gera, na primeira subida, um par RSA descartável e grava a chave privada e um e-mail fictício no `.env`, como faz com o par do JWT, e não os versiona. O emulador não confere a assinatura nem a validade da URL: uma URL adulterada ou vencida também é servida. - **Origem do SDK:** o SDK também lê `STORAGE_EMULATOR_HOST` do ambiente e, com ela definida, usa o valor como endereço-base sem o `/storage/v1`, o que dá 404 em toda chamada contra o emulador (verificado na versão 8.4.0). A implementação passa o endereço por configuração (`apiEndpoint`) e cria o SDK com a variável fora do ambiente, que volta em seguida. - **Os testes no navegador não têm rota de apoio:** eles falam com o storage pelas rotas do produto. Criam a sessão por `POST /uploads`, conferem o tamanho pela conclusão e pedem a URL de leitura a `GET /items/{id}/download-url`. @@ -618,8 +618,8 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to | `GCS_BUCKET` | api | Nome do bucket privado | | `TASKS_QUEUE`, `WORKER_URL` | api | Fila do Cloud Tasks e endereço do worker | | `QUEUE_DRIVER` | api | `cloud-tasks` em staging e produção, `local` no desenvolvimento (chamada HTTP direta ao worker) | -| `STORAGE_EMULATOR_HOST` | api | Endereço do emulador de storage, só no desenvolvimento (`http://storage:4443`) | -| `STORAGE_PUBLIC_URL` | api | Origem do emulador que o navegador alcança, só no desenvolvimento (`http://localhost:4443`). Obrigatória quando `STORAGE_EMULATOR_HOST` existe. | +| `STORAGE_EMULATOR_HOST` | api | Endereço do emulador de storage, só no desenvolvimento (`http://storage:4443` no slot 0) | +| `STORAGE_PUBLIC_URL` | api | Origem do emulador que o navegador alcança, só no desenvolvimento (`http://localhost:4443` no slot 0). Obrigatória quando `STORAGE_EMULATOR_HOST` existe. | | `STORAGE_SIGNER_EMAIL`, `STORAGE_SIGNER_PRIVATE_KEY` | api | Credencial de assinatura das URLs, só no desenvolvimento: um e-mail fictício e uma chave RSA em PEM gerada pelo `setup-env.mjs`. Obrigatórias quando `STORAGE_EMULATOR_HOST` existe, e ausentes em produção, onde a assinatura usa a identidade do serviço. | | `TRASH_RETENTION_DAYS`, `PENDING_UPLOAD_TTL_HOURS` | api | Padrões de 30 dias e 24 horas. O prazo dos pendentes é um inteiro maior ou igual a 0, e zero faz todo pendente vencer na hora. | | `ACCESS_TOKEN_TTL_SECONDS`, `REFRESH_TOKEN_TTL_SECONDS` | api | Validade do token de acesso e do token de renovação, em segundos. Padrões de 900 (15 minutos) e 2592000 (30 dias). | @@ -631,7 +631,7 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to | `DATABASE_CONNECT_TIMEOUT_MS` | api | Quanto esperar o banco para abrir uma conexão, em milissegundos. Padrão de 10000. | | `SMTP_TIMEOUT_MS` | api | Quanto esperar o servidor SMTP para resolver o nome, conectar e saudar, em milissegundos. Padrão de 10000. | | `SMTP_IDLE_TIMEOUT_MS` | api | Quanto uma conexão SMTP aberta pode ficar sem tráfego, durante um envio ou entre um envio e outro, em milissegundos. Padrão de 60000. | -| `WEB_ORIGIN` | api | Origem do frontend, para CORS e links de e-mail: esquema, host e porta, sem caminho nem barra no fim. No desenvolvimento, é o endereço que a pessoa abre no navegador (`http://localhost:3000`). | +| `WEB_ORIGIN` | api | Origem do frontend, para CORS e links de e-mail: esquema, host e porta, sem caminho nem barra no fim. No desenvolvimento, é o endereço que a pessoa abre no navegador (`http://localhost:3000` no slot 0). | | `INTERNAL_API_SECRET` | api e web | Segredo que autoriza a web a repassar o IP do usuário (segredo). Tem pelo menos 32 caracteres, e o valor é o mesmo nos dois projetos. | | `LOGIN_RATE_LIMIT_PER_EMAIL`, `LOGIN_RATE_LIMIT_PER_IP`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | api | Tentativas de login por e-mail e por IP, e a janela, em segundos. Padrões de 5, 20 e 900 (15 minutos). | | `EMAIL_REQUEST_RATE_LIMIT_PER_EMAIL`, `EMAIL_REQUEST_RATE_LIMIT_PER_IP`, `EMAIL_REQUEST_RATE_LIMIT_WINDOW_SECONDS` | api | Cadastros, reenvios de verificação e pedidos de "esqueci a senha" por e-mail e por IP, e a janela, em segundos. Padrões de 3, 10 e 3600 (1 hora). | @@ -645,6 +645,26 @@ A retenção da lixeira, o prazo de upload pendente, a validade dos tokens, a to **Banco de testes:** os testes da API usam um banco separado no mesmo PostgreSQL, com o nome do banco de `DATABASE_URL` mais o sufixo `_test`. Ele não tem variável própria. +**Ambiente local por slot:** cada checkout tem um **slot**, de 0 a 3, que dá ao ambiente de desenvolvimento um bloco próprio de portas na máquina, para vários ambientes ficarem no ar juntos. O slot vem de um `.env` na raiz do checkout, que o Git ignora e que o Compose lê sozinho, com o mesmo `compose.dev.yaml` e o mesmo comando. Sem o arquivo, vale o slot 0, as portas de hoje, e é assim que ficam o checkout principal e a CI. O `.env` da raiz é do Compose e não é o `apps/api/.env`: o Compose passa à API só o que a tabela abaixo manda. + +- **Fórmula:** a porta de hoje mais slot × 10, para a web, a API, o storage e os e-mails. Os endereços entre contêineres não mudam, com uma exceção: o emulador de storage escuta, dentro do contêiner, na porta do slot, e por isso o endereço dele entre contêineres segue o slot (`STORAGE_EMULATOR_HOST`). +- **Dono:** este documento decide os nomes e a fórmula. Quem grava o `.env` (o script de frentes, ou a pessoa à mão) só os segue. +- **Sem padrão na passagem:** as variáveis da API e do `playwright` abaixo não têm valor padrão no Compose: sem o `.env`, elas não existem no ambiente do serviço, e a API usa o `apps/api/.env`, como hoje. Um padrão no Compose as faria existir no slot 0, e a configuração resolvida deixaria de ser igual à de antes do slot. + +| Variável do `.env` | Slot 0 (padrão do Compose) | Slot 1 | Quem lê | +| --- | --- | --- | --- | +| `SLOT` | não precisa existir | `1` | Só informa: o Compose não a lê, e o script de frentes a usa para achar o slot de cada pasta | +| `WEB_PORT` | 3000 | 3010 | Porta publicada da web | +| `API_PORT` | 3001 | 3011 | Porta publicada da API | +| `STORAGE_PORT` | 4443 | 4453 | Porta publicada do storage, e a em que o emulador escuta e se anuncia (`-port` e `-public-host`) | +| `MAILPIT_PORT` | 8025 | 8035 | Porta publicada da tela e da API HTTP dos e-mails | +| `WEB_ORIGIN` | ausente | `http://localhost:3010` | Passada à API, que a usa nos links dos e-mails e no CORS (tabela acima) | +| `STORAGE_PUBLIC_URL` | ausente | `http://localhost:4453` | Passada à API e ao `playwright` (tabela acima, e a seção 4.6) | +| `STORAGE_EMULATOR_HOST` | ausente | `http://storage:4453` | Passada à API (tabela acima, e a seção 4.6) | +| `GIT_MASK_TYPE`, `GIT_MASK_SOURCE` | `tmpfs` e vazio | `bind` e `/dev/null` | A máscara do `.git` no contêiner da API, abaixo | + +**Máscara do `.git`:** o contêiner da API monta o repositório só para leitura e cobre o `.git` por cima, para o histórico, os hooks e a configuração do Git ficarem fora do alcance do que roda nele. No checkout principal e na CI, o `.git` é uma pasta, e a máscara é um `tmpfs`. Num worktree, o `.git` é um arquivo, onde o `tmpfs` não monta e o contêiner não sobe: o `.env` do worktree traz `GIT_MASK_TYPE=bind` e `GIT_MASK_SOURCE=/dev/null`, que cobre o arquivo com um vazio. A varredura de segredos também precisa do histórico, que num worktree está no `.git` comum, fora da pasta dele: o `scripts/verify.sh` monta esse `.git` e aponta o `GIT_DIR` do Git para o do worktree, porque o arquivo `.git` pode trazer um caminho relativo, que no contêiner não leva a lugar nenhum. + Os valores marcados como segredo vêm do Secret Manager e não ficam em arquivos versionados. diff --git a/docs/tickets/LUI-182/LUI-184/aceite.md b/docs/tickets/LUI-182/LUI-184/aceite.md new file mode 100644 index 0000000..69e5ca1 --- /dev/null +++ b/docs/tickets/LUI-182/LUI-184/aceite.md @@ -0,0 +1,49 @@ +# Aceite do LUI-184 + +Ticket de harness: o ambiente de desenvolvimento passa a seguir um slot, lido de um `.env` na raiz do checkout. O CA1 a CA3 são provados por testes de unidade em shell, que rodam na máquina com o `docker compose config` e entram no relatório dos testes em shell com a tag `@LUI-184-CA`. O CA4 e o CA5 exigem dois ambientes e um worktree de verdade no ar, e são de revisão humana, com a evidência no PR. O CA6 é lido no diff. + +Os testes resolvem a configuração pelo `docker compose -f compose.dev.yaml --env-file config --format json`, sempre com o `--env-file` explícito, para o resultado não depender de um `.env` que exista no checkout de quem roda. Nenhum contêiner sobe. O `.env` do slot 1 que os testes usam é o do contrato que a LLD descreve (seção 6, "Ambiente local por slot"): as quatro portas (`WEB_PORT`, `API_PORT`, `STORAGE_PORT`, `MAILPIT_PORT`), os três endereços que a API e o `playwright` leem (`WEB_ORIGIN`, `STORAGE_PUBLIC_URL`, `STORAGE_EMULATOR_HOST`) e a máscara do `.git` (`GIT_MASK_TYPE` e `GIT_MASK_SOURCE`). O `SLOT` também vai no arquivo, mas só informa: o compose não o lê. + +"Igual à de hoje" (CA2) é conferido pelos valores que o ambiente tem hoje: as portas publicadas, a linha do emulador de storage, o endereço do healthcheck dele, a máscara em `tmpfs` e a ausência das variáveis do slot na API e no `playwright`. Na implementação, a configuração resolvida sem `.env` também é comparada, uma vez, com a do compose do commit de plano, para a diferença que sobrar ser só o que o ticket acrescenta. + +## LUI-184-CA1 + +**Marca:** teste de unidade + +**Cenário:** Dado o `.env` do slot 1 (portas 3010, 3011, 4453 e 8035, os endereços `http://localhost:3010`, `http://localhost:4453` e `http://storage:4453`, e a máscara por arquivo), quando o compose resolve a configuração, então a web publica a 3010 na 3000 do contêiner, a API a 3011 na 3000, o storage a 4453 na 4453 e os e-mails a 8035 na 8025; o emulador de storage inicia com `-port 4453` e `-public-host localhost:4453` e o healthcheck dele confere `127.0.0.1:4453`; a API recebe `WEB_ORIGIN`, `STORAGE_PUBLIC_URL` e `STORAGE_EMULATOR_HOST` do slot, e o `playwright` recebe `STORAGE_PUBLIC_URL`; e os endereços entre contêineres que não são do storage (`http://api:3000`, `http://web:3000`, `http://mailpit:8025`, o banco) seguem como hoje. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "o `.env` do slot 1 move as portas e os endereços do ambiente". + +## LUI-184-CA2 + +**Marca:** teste de unidade + +**Cenário:** Dado um arquivo de ambiente vazio, quando o compose resolve a configuração, então as portas publicadas são a 3000, a 3001, a 4443 e a 8025, o emulador de storage inicia com `-port 4443` e `-public-host localhost:4443` e o healthcheck dele confere `127.0.0.1:4443`, a máscara do `.git` é `tmpfs` somente leitura, e a API e o `playwright` não recebem nenhuma das variáveis do slot. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "sem `.env`, a configuração do ambiente é a de hoje". + +## LUI-184-CA3 + +**Marca:** teste de unidade + +**Cenário:** Dado um repositório temporário com o `.git` como pasta e um worktree temporário com o `.git` como arquivo, quando o contêiner sobe com a máscara que a configuração resolvida declara para o contêiner da API (a do `.env` de um worktree para o arquivo, a padrão para a pasta), então o `.git` visto dentro do contêiner fica vazio nos dois casos, sem o histórico, e o contêiner sobe. Como controle, a máscara padrão num `.git` que é arquivo não sobe, que é o defeito que o ticket corrige. O contêiner do teste é o do `postgres`, que o ambiente já baixou, com o mesmo `.:/repo` somente leitura do contêiner da API. + +**Teste previsto:** testes dos scripts do harness, `scripts/test/dev-env-slot.test.sh`, "a máscara do `.git` serve para o `.git` que é pasta e para o que é arquivo". + +## LUI-184-CA4 + +**Marca:** revisão humana + +**Cenário:** Dado o checkout principal e um worktree no slot 1, com o `.env` feito à mão, quando os dois ambientes sobem, então os dois ficam no ar ao mesmo tempo, as suítes completas dos dois passam, um link de e-mail e uma URL de download do worktree trazem as portas do slot 1 (3010 e 4453), e derrubar um ambiente deixa o outro de pé. A evidência no PR traz os comandos e as saídas: o `docker compose ps` dos dois, o `scripts/verify.sh full` de cada um, o e-mail aberto na tela do slot 1 (porta 8035), a URL de download e o `down` de um com o outro respondendo. + +## LUI-184-CA5 + +**Marca:** revisão humana + +**Cenário:** Dado um worktree irmão do checkout principal, quando se roda o `scripts/verify.sh full` nele, então o passo "Segredos nos commits da branch" passa, e ele acusa um segredo de mentira que a pessoa põe num commit do worktree. Dentro do contêiner da API desse worktree, o `.git` está vazio e o histórico do Git não está ao alcance. A evidência no PR traz os dois resultados do passo e a saída do `ls -A` e do `cat` do `/repo/.git` no contêiner. + +## LUI-184-CA6 + +**Marca:** documento + +**Cenário:** Dado o diff do PR, quando se leem a LLD (a seção do emulador de storage, a tabela de variáveis e o contrato do `.env` do slot), a tabela de endereços do `AGENTS.md` da raiz, os `AGENTS.md` da web e da API e o README, então cada um descreve os endereços por slot onde citava `localhost` com porta fixa: a tabela da raiz diz que é a do slot 0, e a fórmula (a porta de hoje mais slot × 10) e os nomes das variáveis têm um dono só, a LLD. diff --git a/scripts/git-mount.sh b/scripts/git-mount.sh new file mode 100644 index 0000000..63064ea --- /dev/null +++ b/scripts/git-mount.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# O que o contêiner da varredura de segredos do scripts/verify.sh precisa para ler o histórico do Git +# do checkout, além da pasta montada. Carregado com `source`, define `gitleaks_git_args`, que imprime, +# um por linha, os argumentos extras do `docker run` (a pasta atual é montada em /repo). +# +# Num repositório, o `.git` é uma pasta dentro da pasta montada, e nada mais é preciso. Num worktree, é um +# arquivo que aponta para o `.git` comum, fora dela, às vezes com caminho relativo, que no contêiner não +# leva a lugar nenhum: o `.git` comum é montado no mesmo caminho absoluto da máquina, só para leitura, e o +# `GIT_DIR` aponta para a pasta do worktree dentro dele (docs/lld.md, seção 6, "Máscara do `.git`"). A forma +# `--mount` é a que vale: a `-v origem:destino:ro` não monta um caminho igual nos dois lados no Docker Desktop. + +gitleaks_git_args() { + # Um `.git` que é pasta (repositório) ou que não existe (fora do Git) não pede nada. + [[ -f .git ]] || return 0 + local common gitdir + { read -r common && read -r gitdir; } < <(git rev-parse --path-format=absolute --git-common-dir --absolute-git-dir) || + return 1 + printf '%s\n' --mount "type=bind,source=$common,target=$common,readonly" \ + -e "GIT_DIR=$gitdir" -e GIT_WORK_TREE=/repo +} diff --git a/scripts/test/dev-env-slot.test.sh b/scripts/test/dev-env-slot.test.sh new file mode 100755 index 0000000..8ba8739 --- /dev/null +++ b/scripts/test/dev-env-slot.test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Testes do ambiente de desenvolvimento por slot (compose.dev.yaml), pela configuração que o Compose +# resolve, sem subir contêiner: o `.env` do slot move as portas e os endereços, sem ele o ambiente é o +# de hoje, e a máscara do `.git` serve para um `.git` que é pasta e para um que é arquivo. O contrato do +# `.env` (os nomes das variáveis e a fórmula das portas) é o do docs/lld.md, seção 6, "Ambiente local por +# slot". Rodam na máquina, porque usam o `docker compose`. Quem chama é o scripts/verify.sh. + +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=sh-helpers.sh +source "$ROOT/scripts/test/sh-helpers.sh" + +for tool in docker jq git; do + command -v "$tool" >/dev/null || { echo "Falta o \`$tool\` na máquina: o teste do ambiente por slot precisa dele."; exit 2; } +done + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# O `.env` do slot 1, como o contrato da LLD o descreve, para o worktree: as portas de hoje mais 10, os +# três endereços que a API e o `playwright` leem e a máscara do `.git` por arquivo. +cat >"$work/slot1.env" <<'ENV' +SLOT=1 +WEB_PORT=3010 +API_PORT=3011 +STORAGE_PORT=4453 +MAILPIT_PORT=8035 +WEB_ORIGIN=http://localhost:3010 +STORAGE_PUBLIC_URL=http://localhost:4453 +STORAGE_EMULATOR_HOST=http://storage:4453 +GIT_MASK_TYPE=bind +GIT_MASK_SOURCE=/dev/null +ENV +# Sem `.env`: um arquivo vazio, passado de forma explícita, para um `.env` do checkout de quem roda não +# entrar no resultado. +: >"$work/slot0.env" + +# As duas configurações resolvidas, em JSON, uma vez para todos os casos. +for slot in slot0 slot1; do + (cd "$ROOT" && docker compose -f compose.dev.yaml --env-file "$work/$slot.env" config --format json) >"$work/$slot.json" || + { echo "O Compose não resolveu a configuração do $slot."; exit 2; } +done + +# has : a configuração resolvida do slot satisfaz o filtro. +has() { + jq -e "$2" "$work/$1.json" >/dev/null +} + +# expect_ports : as portas publicadas, e o storage escutando e se +# anunciando na dele, com o healthcheck na mesma. +expect_ports() { + local slot="$1" web="$2" api="$3" storage="$4" mailpit="$5" + has "$slot" "[.services.web.ports[] | [.published, .target]] == [[\"$web\", 3000]]" && + has "$slot" "[.services.api.ports[] | [.published, .target]] == [[\"$api\", 3000]]" && + has "$slot" "[.services.storage.ports[] | [.published, .target]] == [[\"$storage\", $storage]]" && + has "$slot" "[.services.mailpit.ports[] | [.published, .target]] == [[\"$mailpit\", 8025]]" && + has "$slot" ".services.storage.entrypoint[-1] | contains(\"-port $storage \") and endswith(\"-public-host localhost:$storage\")" && + has "$slot" ".services.storage.healthcheck.test[-1] | contains(\"127.0.0.1:$storage/\")" +} + +# --- CA1: o `.env` do slot 1 move as portas e os endereços --- +ca1() { + expect_ports slot1 3010 3011 4453 8035 && + has slot1 '.services.api.environment | .WEB_ORIGIN == "http://localhost:3010" and .STORAGE_PUBLIC_URL == "http://localhost:4453" and .STORAGE_EMULATOR_HOST == "http://storage:4453"' && + has slot1 '.services.playwright.environment.STORAGE_PUBLIC_URL == "http://localhost:4453"' && + # Os endereços entre contêineres que não são do storage seguem como hoje. + has slot1 '.services.web.environment.API_URL == "http://api:3000" and .services.playwright.environment.WEB_URL == "http://web:3000" and .services.playwright.environment.MAILPIT_URL == "http://mailpit:8025" and .services.playwright.environment.API_URL == "http://api:3000" and .services.playwright.environment.DATABASE_URL == "postgresql://app:app@postgres:5432/app"' +} +check "o .env do slot 1 move as portas e os endereços do ambiente @LUI-184-CA1" ca1 + +# --- CA2: sem `.env`, o ambiente é o de hoje --- +ca2() { + expect_ports slot0 3000 3001 4443 8025 && + # Uma variável sem valor e sem `.env` sai nula: o Compose não a passa ao contêiner. + has slot0 '[.services.api.environment.WEB_ORIGIN, .services.api.environment.STORAGE_PUBLIC_URL, .services.api.environment.STORAGE_EMULATOR_HOST, .services.playwright.environment.STORAGE_PUBLIC_URL] | all(. == null)' && + has slot0 '[.services.api.volumes[] | select(.target == "/repo/.git") | [.type, .read_only]] == [["tmpfs", true]]' +} +check "sem .env, a configuração do ambiente é a de hoje @LUI-184-CA2" ca2 + +# --- CA3: a máscara do `.git` serve para o `.git` que é pasta e para o que é arquivo --- + +# A imagem do storage, a menor das do ambiente e que o `up` já baixou: o teste só precisa de um `sh`. +IMAGE="$(jq -r '.services.storage.image' "$work/slot0.json")" +docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "Falta a imagem $IMAGE na máquina: suba o ambiente com \`docker compose -f compose.dev.yaml up -d --wait\` e rode de novo." + exit 2 +} + +# in_container : roda num contêiner que tem a pasta em /repo, só para leitura como +# no contêiner da API, coberta pela máscara que a configuração do slot declara para o `.git` (o tipo, a +# origem se há, e somente leitura). Confere que ele não vê nem o histórico (o HEAD de um `.git` que é pasta) +# nem o ponteiro do worktree (o `gitdir:` de um `.git` que é arquivo). +in_container() { + local mask + mask="$(jq -r '.services.api.volumes[] | select(.target == "/repo/.git") + | "type=\(.type),target=\(.target),readonly" + (if .source then ",source=\(.source)" else "" end)' "$work/$2.json")" + docker run --rm --pull=never --entrypoint sh \ + --mount "type=bind,source=$1,target=/repo,readonly" --mount "$mask" \ + "$IMAGE" -c '[ ! -e /repo/.git/HEAD ] && ! grep -q gitdir /repo/.git 2>/dev/null' >/dev/null 2>&1 +} + +ca3() { + local repo="$work/repo" worktree="$work/worktree" + git init -q "$repo" && + git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m inicial && + git -C "$repo" worktree add -q --detach "$worktree" && + # Pasta: a máscara padrão, a do checkout principal e da CI. + in_container "$repo" slot0 && + # Arquivo: a máscara do `.env` de um worktree. + in_container "$worktree" slot1 && + # Controle: a máscara padrão não sobe sobre um `.git` que é arquivo, o defeito que o ticket corrige. + ! in_container "$worktree" slot0 +} +check "a máscara do .git serve para o .git que é pasta e para o que é arquivo @LUI-184-CA3" ca3 + +finish "do ambiente de desenvolvimento por slot" diff --git a/scripts/test/git-mount.test.sh b/scripts/test/git-mount.test.sh new file mode 100755 index 0000000..ee79ed1 --- /dev/null +++ b/scripts/test/git-mount.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Testes do scripts/git-mount.sh, que diz o que o contêiner da varredura de segredos precisa para ler o +# histórico do Git: nada num repositório (onde o `.git` é uma pasta, dentro da pasta montada), e o `.git` +# comum e o `GIT_DIR` num worktree (onde o `.git` é um arquivo que aponta para fora dela, com caminho +# relativo ou absoluto, conforme a configuração do Git de quem o criou). O Docker não entra: o teste confere +# os argumentos. Rodam na máquina, porque usam o `git`. Quem chama é o scripts/verify.sh. + +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=sh-helpers.sh +source "$ROOT/scripts/test/sh-helpers.sh" +# shellcheck source=../git-mount.sh +source "$ROOT/scripts/git-mount.sh" + +command -v git >/dev/null || { echo "Falta o \`git\` na máquina: o teste precisa dele."; exit 2; } + +# O caminho físico, sem links simbólicos: é o que o Git imprime. +work="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$work"' EXIT +repo="$work/repo" +git init -q "$repo" +git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m inicial + +# args_in : os argumentos que a função dá para o checkout da pasta, um por linha. +args_in() { + (cd "$1" && gitleaks_git_args) +} + +# Os argumentos de um worktree irmão do repositório, que o Git gravou com o caminho relativo ou o absoluto. +expected_for_worktree() { + printf '%s\n' --mount "type=bind,source=$repo/.git,target=$repo/.git,readonly" \ + -e "GIT_DIR=$repo/.git/worktrees/$1" -e GIT_WORK_TREE=/repo +} + +check "num repositório, onde o .git é uma pasta, não pede nada" test -z "$(args_in "$repo")" + +git -C "$repo" -c worktree.useRelativePaths=true worktree add -q --detach "$work/relativo" +check "o worktree está com o .git relativo, como no Git de quem o cria assim" grep -q '^gitdir: \.\./' "$work/relativo/.git" +check "num worktree com o .git relativo, monta o .git comum e aponta o GIT_DIR" \ + test "$(args_in "$work/relativo")" = "$(expected_for_worktree relativo)" + +git -C "$repo" -c worktree.useRelativePaths=false worktree add -q --detach "$work/absoluto" +check "o worktree está com o .git absoluto" grep -q "^gitdir: $repo/" "$work/absoluto/.git" +check "num worktree com o .git absoluto, pede o mesmo" \ + test "$(args_in "$work/absoluto")" = "$(expected_for_worktree absoluto)" + +finish "do scripts/git-mount.sh" diff --git a/scripts/verify.sh b/scripts/verify.sh index 33a0934..8125822 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -78,6 +78,10 @@ APPROVAL_FILE="$HARNESS_DIR/full-ok" FULL_LOCK_FILE="$HARNESS_DIR/full-lock" # shellcheck source=full-lock.sh source scripts/full-lock.sh +# O que o contêiner da varredura de segredos precisa para ler o histórico num worktree, com a função +# `gitleaks_git_args` do scripts/git-mount.sh. +# shellcheck source=git-mount.sh +source scripts/git-mount.sh # A pasta dos tickets. TICKETS_DIR=docs/tickets @@ -335,8 +339,14 @@ require_base() { } secret_scan() { + local extra=() mounts require_base - docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/repo" "$GITLEAKS_IMAGE" \ + # Num worktree, o histórico está no `.git` comum, fora da pasta montada; num repositório, não há extra. + mounts="$(gitleaks_git_args)" || + abort "não deu para achar o .git comum deste checkout, que a varredura de segredos monta." \ + "confira se o repositório do Git está íntegro, com \`git status\`." + [[ -z "$mounts" ]] || mapfile -t extra <<<"$mounts" + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/repo" "${extra[@]}" "$GITLEAKS_IMAGE" \ git /repo --no-banner --redact --log-opts="$BASE_REF..HEAD" }