# Spec 17 — Ciclo de vida de produção: `scripts/init_prod.sh` e `scripts/update_prod.sh`

> **Dono:** Runtime/Infra · **Status:** Proposta · **Plano:** 04
> **Relaciona:** ADR-005, ADR-008, ADR-013, ADR-014, ADR-022, ADR-031, ADR-042, ADR-055
> **Objetivo:** inicializar e atualizar a produção **sem `make`**, em fases pequenas,
> **dry-run por padrão**, com backup provado antes de qualquer mudança, build antes do
> down, logs datados e rollback consciente. O antigo `up_prod.sh` foi removido; este é o
> caminho único de produção.

## 1. Contrato

```bash
scripts/init_prod.sh   [--execute] [--cache] [--allow-dirty]
scripts/update_prod.sh [--execute] [--no-pull] [--backup-only] [--cache] [--allow-dirty]
```

- **Dry-run é o padrão**: sem `--execute` nada é criado, baixado, escrito, parado ou
  iniciado; o log mostra o plano completo. `--cache` reutiliza o cache de build (o padrão
  de produção é `build --no-cache`). `--allow-dirty` só existe para operação excepcional.
- **Sem `make`**: os entrypoints falam direto com Docker/Compose, git, curl, tar e com os
  helpers de `tools/` (`project-name`, `data-dirs`, `pgdata-adopt`, `wal-retention`).
- **Docker é o único runtime**: podman é opt-in de dev (ADR-045); `.env` com
  `CONTAINER_RUNTIME=podman` é rejeitado. Não existe `docker-compose.prod.yml` (ADR-032).
- **Um arquivo por fase** em `scripts/init/` e `scripts/update/`; o entrypoint apenas
  ordena. A ordem é contrato (testada em `DeployPipelineTest`).
- **Log**: `storage/logs/{init,update}-{dry-run,execute}-<UTC>.log`, com comandos,
  saída, duração, fase que falhou e resumo final. Segredos nunca vão para argv/log.
- **Sem interação**: pensado para operador e CI. O entrypoint usa `flock` (um deploy por
  vez) e `umask 077`; `.env` e backups ficam em `600`. Falha de `up`/health imprime
  `docker compose ps` + últimas 80 linhas de cada serviço antes de abortar.

## 2. Preflight (antes de qualquer efeito)

- `.env` existe; `APP_ENV=production`; `APP_URL` **https** com host real (sem
  `example.*`/localhost); `APP_NAME` presente.
- **Segredos obrigatórios** (falha nomeando a variável, sem imprimir valor):
  `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, `REDIS_PASSWORD`, `AWS_ACCESS_KEY_ID`,
  `AWS_SECRET_ACCESS_KEY`; valor de exemplo (`change-me`, `example`, …) aborta.
- `APP_KEY`: **pode estar vazia no init** (o init gera); **obrigatória no update**.
- PostgreSQL do Compose (`DB_CONNECTION=pgsql`, `DB_HOST=postgres`) — o backup/restore só
  cobre esse banco; `WEB_PORT` válida (1024–65535); `DATA_PATH`/`STORAGE_PATH` graváveis;
  `BACKUP_KEEP` sanitizado (default 7).
- Ferramentas (`docker`, `compose`, `git`, `flock`, `curl`, `tar`, `gzip`, `sha256sum`,
  `realpath`, …), daemon Docker ativo, espaço livre, usuário não-root. Avisos explícitos para
  `DATA_PATH` dentro do checkout (cota do home/LVE no cPanel) e Docker rootless; os logs do
  `up`/health são impressos no local da falha (postgres/valkey/storage/php-fpm/queue/web).
- **Colisões**: o nome do projeto resolvido não pertence a outro checkout; `WEB_PORT` não
  é publicada por outra instalação. A stack anterior do **próprio** checkout é permitida
  (será adotada pelo update; ADR-055).
- **Adoção**: o update detecta uma stack deste checkout sob outro nome e a usa para o
  backup/adoção; se houver duas stacks ambíguas, aborta. O init recusa uma instalação
  anterior e direciona ao update (nunca cria um banco vazio ao lado do real).

## 3. Sequência — `init_prod.sh` (primeira instalação)

| Fase | Ação |
|---|---|
| `00-preflight` | valida `.env`, ferramentas, Docker, espaço, colisões, git, compose config |
| `01-identity` | resolve o nome único; grava `COMPOSE_PROJECT_NAME` e `PHP_IMAGE` no `.env`; cria `DATA_PATH`/`STORAGE_PATH` + marcador de posse; impede PGDATA alheio |
| `02-assets` | `npm ci && npm run build` na imagem Node pinada; o `node_modules` é removido ao final (o runtime não usa node e a cota do home é do PGDATA) |
| `03-images` | `pull` das imagens de infra pinadas + `build` da imagem `php-fpm` **do projeto**; exige PHP 8.4.27+/8.5.12+ |
| `04-composer` | `composer install --no-dev --no-scripts` (vendor no bind; scripts ficam para depois) |
| `05-up` | `docker compose up -d --remove-orphans --wait` (containers, rede, volumes e porta únicos) |
| `06-laravel` | `optimize:clear`; `key:generate` se vazia; `dump-autoload`/package discovery; `migrate --force --isolated`; `storage:ensure-bucket`; `core:cache-modules`; `optimize`; USR2; `queue:restart` |
| `07-verify` | `/up` 200, portal 200/3xx, CMS `/login` 200 (Host do domínio), `caddy validate`, `migrate:status` sem pendências, SBOM, aviso do edge HTTPS (ProxyPass/SSL do WHM é externo) |

- `migrate:status` num banco novo (sem tabela de migrations) é **evidência**, não
  pré-condição; o `migrate` abaixo é que cria/aplica.
- Nenhum seed de demonstração em produção (ADR-041). Usuários/conteúdo entram pelo CMS.
- O init é idempotente para retomar uma instalação interrompida; não faz backup (não é
  update) e avisa quando já existem containers.

## 4. Sequência — `update_prod.sh` (instalação existente)

| Fase | Ação |
|---|---|
| `00-preflight` | igual ao init, com `APP_KEY` obrigatória; exige stack inicializada; recusa legacy ambíguo |
| `01-ensure-stack` | `compose start` (não recria) na stack viva (nova ou anterior), espera saúde e prova que o banco responde |
| `02-backup` | **primeira ação**: WAL retention (best-effort) → `pg_dump -Fc` validado por `pg_restore --list` → snapshot do RustFS (storage parado só durante o tar e religado) → snapshot do `storage/` Laravel → `media:backup` offsite (quando configurado) → retenção `BACKUP_KEEP`; push do dump ao disco offsite quando `BACKUP_OFFSITE=1` |
| `03-code` | `git fetch` + `git pull --ff-only` (ou `--no-pull`); registra commits antes/depois |
| `04-assets` | `npm ci && npm run build` **antes do down**; `node_modules` removido ao final (cota/LVE) |
| `05-build` | persiste nome/imagem no `.env`; preserva a imagem viva em `:previous`; `build` da imagem nova **antes do down** |
| `06-switch` | classifica/adota o PGDATA (`pgdata-adopt`: 0 ok, 10 copia do volume anônimo, 11/12 aborta); `down --remove-orphans` **sem `-v`**; preserva o vendor (`vendor.pre-*`), `composer install --no-dev --no-scripts`, `up --wait`; falha restaura vendor/imagem anterior |
| `07-migrate` | `optimize:clear`; `dump-autoload`; `migrate:status` (evidência) + `migrate --force --isolated`; bucket; `core:cache-modules`; `optimize`; USR2; `queue:restart` |
| `08-verify` | mesma verificação HTTP/Caddy/migrações/SBOM; move o `vendor.pre-*` para `.pi/trash`; atualiza o marcador de posse |

- **Sem backup provado, não há pull/build/down.** Dump vazio/ilegível ou tar inválido
  aborta. O storage é parado apenas para a cópia consistente do RustFS e sempre religado.
- **Build antes do down** (assets + imagem) e **down sem `-v`**: volumes/PGDATA nunca são
  apagados. O `composer install` roda com o banco parado; o vendor anterior fica em
  `vendor.pre-*` e é restaurado se o install falhar.
- **Adoção (ADR-055)**: se a stack viva deste checkout está sob o nome anterior, o update
  faz o backup nela, classifica o PGDATA (`tools/pgdata-adopt.sh`), copia o cluster quando
  necessário, derruba a stack antiga e sobe o projeto novo no mesmo `DATA_PATH`. A imagem
  antiga é tagueada como `:previous` para o rollback de runtime.
- **`--backup-only`**: executa preflight + start + backup e para (cron/operação), sem
  pull/build/down/migrate.

## 5. Isolamento (ADR-055)

- `COMPOSE_PROJECT_NAME` explícito vence; senão `slug(APP_NAME)-<hash8 do checkout>`.
  O init grava o valor no `.env`; o update o persiste se faltar.
- `PHP_IMAGE=<projeto>-php-fpm` (o `queue` reusa a imagem); a tag compartilhada
  `concordia/php-fpm:local` é apenas fallback de inspeção manual. Containers, redes,
  volumes nomeados e a porta `WEB_PORT` são do projeto; o `DATA_PATH` tem marcador de
  posse (`checkout=`) para impedir dois checkouts no mesmo PGDATA.

## 6. Otimizações e saúde (obrigatórias)

- PHP **8.4.27+** ou **8.5.12+** (CVE-2026-109618/109619); imagem base/pins do ADR-031.
- `php artisan optimize` (config/routes/views/events) + `core:cache-modules`; **USR2** no
  FPM (OPcache `validate_timestamps=0`) + `queue:restart`; `storage:ensure-bucket`
  idempotente; **sem `storage:link`** (ADR-030); SBOM CycloneDX; health `/up`.
- O bucket `offsite-media` é garantido quando `OFFSITE_MEDIA_BUCKET` existe.

## 7. Falha e rollback

- O update **não** faz reset de git nem restaura o banco automaticamente. Em falha:
  - antes do down: nada foi alterado (backup registrado);
  - composer falhou: o vendor anterior é restaurado e a stack religada;
  - `up` falhou: tenta a imagem `:previous`; se também falhar, deixa o diagnóstico e o
    backup no log para intervenção;
  - migrate falhou: o app fica no ar, o dump e o caminho estão no log; o restore é uma
    decisão humana (`tools/db-restore-drill.sh`/`pg_restore`).
- O log datado é o registro de evidência; o estado (`storage/backups/.update-state`)
  guarda commits, imagem e caminhos de backup para o resumo.

## 8. Invariantes testados

- `DeployPipelineTest`: ordem `02-backup` antes de código/build/switch/migrate; WAL antes
  do dump; validação `pg_restore --list`; preflight de segredos; lock/umask/`chmod 600`;
  USR2/optimize; sem `storage:link`; down sem volumes; vendor/imagem para rollback;
  `--dry-run`/`--backup-only`; CI chama `scripts/update_prod.sh --execute`; `up_prod.sh`
  **não existe**.
- `make check` verde (PHPStan max, cobertura, gates, a11y) e `make check E2E=1` no
  fechamento de escopo.

## 9. Operação (fora do container)

- O Apache do WHM termina TLS e encaminha para `127.0.0.1:${WEB_PORT}` via `ProxyPass`;
  o init/verify prova o app pelo `Host` do domínio e apenas avisa quando a URL pública
  HTTPS ainda não responde (o edge é externo à stack).
- Backup diário: `scripts/update_prod.sh --execute --backup-only` no cron do host; os
  dumps/tars ficam em `$STORAGE_PATH/backups` (ignorados pelo git) com `.sha256`.
