# Runbook — Deploy, rollback e recuperação

| Campo | Valor |
|---|---|
| **Dono** | Infra/Operação (Core) + dono do cliente |
| **Fase** | F6.I1.T3 / F6.I2 (`wiki/implementation/06-deploy-migracao.md`) |
| **ADRs** | ADR-005, ADR-008, ADR-030, ADR-042 |
| **Specs** | `wiki/specs/17-up-prod.md`, `wiki/specs/16-edge-apache-whm.md` |

> **Premissa (ADR-042):** o V7 é projeto novo, **sem retrocompatibilidade**. Não há migração de dados
> do v5, não há janela de corte, não há ETL. Conteúdo e usuários entram **à mão** pelo CMS.
> O que este runbook garante é **subir, reverter e recuperar** o banco do próprio V7.

---

## 1. Deploy (rotina)

```bash
cd public_html                    # clone do cliente (ADR-005)
scripts/update_prod.sh --execute              # pull + build + backup + migrate + optimize + healthcheck

> **cPanel/LVE:** o `DATA_PATH` padrão (`./s3-local`) fica dentro do home, que tem cota. O
> PGDATA e o RustFS precisam de espaço; `node_modules` (artefato de build) agora é removido
> ao final das fases de assets. Se o `postgres` ficar unhealthy, os scripts imprimem
> `docker compose ps` + logs dos serviços; em Docker rootless verifique o chown/volume do
> postgres (a produção assume Docker rootful).
```

O script é **idempotente** (rodar 2× termina 0 sem mudar o estado) e **reversível** (falha em
qualquer fase → rollback). Fases (`--dry-run` mostra o plano sem alterar nada):

| Fase | O que faz |
|---|---|
| 1–2 | `git pull` (ou `--no-pull`) + imagens pinadas por digest (ADR-031) |
| 3–4 | `docker compose up -d` + espera `postgres`/`valkey`/`storage` ficarem `healthy` |
| 5 / 5.5 | `composer install --no-dev --classmap-authoritative` + assets (Vite, build offline) |
| **5.9** | **`pg_dump -Fc` do banco, validado com `pg_restore --list`** — falha **aborta** o deploy |
| 6 | `migrate:status` (evidência no log) + `php artisan migrate --force --isolated` |
| 7 | `core:cache-modules` + `optimize` + bucket S3 (sem `storage:link` — ADR-030) |
| 8 | recarrega `php-fpm` (`USR2`, PID do master antes/depois), `queue:restart`, `caddy validate`, restart do `web` |
| 9–10 | healthcheck `GET /up` (30 tentativas) + resumo JSON |

Flags: `--dry-run`, `--no-pull`, `--skip-migrate` (schema já aplicado), `--branch <nome>`,
`--allow-dirty` (rollback **não** faz `reset`), `--backup-only`.

Logs: `storage/logs/deploy-<ts>.log` + linhas JSON por fase (`phase`, `duration_ms`).

> **Atenção em ambiente de desenvolvimento:** o deploy roda `composer install --no-dev`. Com o repo
> bind-montado isso **remove** phpunit/phpstan/pint de `vendor/` — depois de ensaiar `scripts/update_prod.sh --execute`
> em dev, rode `composer install` para voltar a ter as dev deps (`make check` depende delas). Em
> produção é o comportamento correto.

**Provado (2026-10-20):** duas execuções seguidas terminam 0 (~117 s cada, mesmas imagens/serviços) e
uma falha injetada no meio (Caddyfile inválido no `caddy validate`, fase 8) aciona o **rollback** com
saída 1, mantendo o app no ar.

### Variáveis do deploy (opcionais; os defaults estão no script)

> Nenhuma delas precisa estar no `.env` — o `scripts/update_prod.sh` usa o padrão da tabela. Documentação
> completa em `config/_OTHER_CONFIGS.md` §2.

| Chave | Padrão | Papel |
|---|---|---|
| `BACKUP_KEEP` | `7` | Quantos dumps manter em `$STORAGE_PATH/backups/` (os mais novos) |
| `BACKUP_OFFSITE` | `0` | `1` = copiar cada dump para o disco escolhido em `BACKUP_DISK` |
| `BACKUP_DISK` | `s3` | `offsite` = endpoint externo (`OFFSITE_*`); `s3` = RustFS local (não é offsite) |

## 2. Backup

```bash
scripts/update_prod.sh --execute --backup-only # dump avulso, sem deploy (operação do dia a dia)
ls -l storage/backups/            # db-<UTC>.dump  (contêm dado pessoal: fora do git — .gitignore)
```

**Backup diário (cron do host)** — o dump é o que garante o RPO; sem ele, o pior caso é
perder tudo desde o último deploy. A linha é do **host** (o `scripts/update_prod.sh` entra no
container pelo Compose):

```cron
23 2 * * * cd /home/<acct>/public_html && scripts/update_prod.sh --execute --backup-only >> storage/logs/backup-daily.log 2>&1
```

- `scripts/update_prod.sh --execute --backup-only` já existe e **valida** o dump (`pg_restore --list`); um dump
  vazio ou ilegível **aborta** — o cron não pode registrar sucesso com dump inválido.
- Sem container novo e sem imagem nova: é o mesmo `scripts/update_prod.sh` do deploy (ADR-001).
- A retenção é por `BACKUP_KEEP` dumps; com `BACKUP_OFFSITE=1` cada dump também vai para
  a cópia remota (a local **não** protege contra perda do host).
- O mesmo host já tem o cron de retenção do WAL (mais abaixo, 03:17) — os dois são
  independentes: um apaga WAL arquivado, o outro produz dump novo.

**Agenda do Laravel (cron do host)** — a agenda (`routes/console.php`) é onde vivem a
poda de `failed_jobs` (48 h, D5), o expurgo mensal da trilha de auditoria (ADR-035), o
expurgo de retenção de domínio (ADR-019) e o **backup diário da mídia** para o bucket
offsite (D7). **Sem container novo**: é o mesmo
`php artisan schedule:run` no container de app, disparado pelo cron do host.

```cron
37 3 * * * cd /home/<acct>/public_html && docker compose exec -T php-fpm php artisan schedule:run >> storage/logs/schedule.log 2>&1
```

- O `schedule:run` roda **uma** vez por dia: as tarefas mensais disparam naquele dia (o
  Laravel reexecuta a cada minuto enquanto o runner está vivo; com o runner do cron, a
  periodicidade é do **cron**).
- O runner é o `schedule:run` (não `schedule:work`): um processo por vez, sem container
  extra (ADR-001). Se o host não roda cron, a agenda **não roda** — a poda de
  `failed_jobs`, os expurgos mensais e o **backup da mídia** ficam pendentes (é a mesma
  premissa do dump diário).

**Backup da mídia (D7)** — o bucket da mídia é single-drive: sem cópia fora do host, a
perda do host perde as imagens publicadas (o dump do banco não as recupera).

- `php artisan media:backup [--dry-run] [--target=offsite-media]` — copia a mídia para o
  bucket offsite e grava o manifesto (`manifests/media-backup.json`) com o SHA-256 de cada
  objeto. É **idempotente** (o que já está no destino com o mesmo conteúdo não é reescrito)
  e **verificado** (o destino é relido; cópia truncada falha).
- `php artisan media:restore --to=s3` — devolve ao destino **exatamente** o que o manifesto
  lista. Para um exercício: aponte `--to` para um bucket de scratch e compare os SHA-256.
- `php artisan media:inventory` — inventário de integridade (objetos, tamanho e SHA-256 por
  prefixo) em `build/media-inventory.json`. É a evidência antes/depois de qualquer troca
  de engine do storage.
- **O destino é configuração**: `OFFSITE_ENDPOINT` + `OFFSITE_MEDIA_BUCKET`. Apontado para o
  mesmo RustFS, a cópia protege contra apagamento, corrupção e *overwrite* — **não** contra
  a perda do host. Sem `OFFSITE_MEDIA_BUCKET`, o `scripts/update_prod.sh` avisa e faz apenas o snapshot local do RustFS
  alto (um backup que não acontece em silêncio é pior do que um que grita).

Invariantes:

- **Sem backup não há migrate** — dump vazio ou ilegível aborta o deploy.
- Retenção por `BACKUP_KEEP`; o dump é `pg_dump -Fc --no-owner --no-acl` (restaurável em base limpa).
- Cópia remota (`BACKUP_OFFSITE=1`): `core:backup:push` confere o **tamanho** no destino; divergência
  remove a cópia e falha o deploy. Depois do envio o deploy roda `core:backup:prune --keep=$BACKUP_KEEP`
  (retenção **fora do host**; falha na poda não derruba o deploy — o backup local já está validado).
- `core:backup:prune --keep=N [--dry-run]` pode rodar avulso; ele ordena pelo **nome** (timestamp
  UTC), nunca pelo mtime do objeto. `--dry-run` **não** registra `core.backup_prune` (nada mudou) e
  uma remoção que falha devolve código de saída ≠ 0.
- `core:backup:push` só aceita `backups/*.dump`: nenhum outro arquivo de `storage/` (log, payload)
  sai do host — a cópia remota tem retenção longa e não pode carregar PII (ADR-019).
- `core:backup:pull --disk=offsite [--path=…]` baixa o dump mais recente e **confere o tamanho**;
  cópia truncada é removida (nunca alimenta um restore "verde").

> **Limite do “offsite” (G-15):** o disco `s3` é o **RustFS do mesmo Compose** (mesmo host).
> `BACKUP_OFFSITE=1` com ele protege contra corrupção/perda do banco, **não** contra a perda do
> host — o `scripts/update_prod.sh` avisa quando `BACKUP_DISK=s3` e o endpoint é local. Para o DR prometido
> no F6: configure `OFFSITE_ENDPOINT`/`OFFSITE_BUCKET` e `BACKUP_DISK=offsite` (troca só de `.env`,
> ADR-030). O ensaio passa a partir da cópia: `tools/db-restore-drill.sh --from-offsite`.

### Runtime: queries lentas e memória

`pg_stat_statements` é criado por migration idempotente (ADR-017) e o `shared_preload_libraries`
já o carrega. Top-N por tempo **total** (custo acumulado, não a média de uma execução isolada):

```bash
docker compose -p concordia exec -T postgres psql -U concordia -d concordia -c "
  SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
         round(mean_exec_time::numeric, 2) AS mean_ms, rows,
         left(query, 120) AS query
  FROM pg_stat_statements
  ORDER BY total_exec_time DESC
  LIMIT 20;"
```

Valkey tem teto (`maxmemory 256mb`, `maxmemory-samples 5`, política `volatile-lru`): a conta é
**sessões + cache + fila**, não o acervo. Sessão de CMS ≈ 2 KB (500 simultâneas ≈ 1 MB); o
grosso é o cache de resposta (objetos pequenos — o HTML é cacheado na borda com ETag) e rajadas
da fila. `volatile-lru` nunca evicta chave **sem** TTL (sessões/filas): se `evicted_keys` subir
em regime, o teto está baixo ou há chave sem TTL — investigar antes de aumentar.

```bash
# `VALKEYCLI_AUTH` (e não `-a`): a senha no environment do comando, nunca no argv (o `ps` do
# host mostra o argv de qualquer processo).
docker compose -p concordia exec -T valkey sh -c \
  'VALKEYCLI_AUTH="$REDIS_PASSWORD" valkey-cli CONFIG GET maxmemory maxmemory-policy maxmemory-samples'
docker compose -p concordia exec -T valkey sh -c \
  'VALKEYCLI_AUTH="$REDIS_PASSWORD" valkey-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"'
```

## 3. Restore (ensaio obrigatório antes de ir ao ar)

```bash
tools/db-restore-drill.sh                # dump mais recente
tools/db-restore-drill.sh --file storage/backups/db-20261019T232800Z.dump
tools/db-restore-drill.sh --keep         # mantém a base restaurada para inspeção
tools/db-restore-drill.sh --from-offsite # baixa a cópia do disco `offsite` e mede o RTO dela
tools/db-restore-drill.sh --from-offsite --disk=s3   # ensaio do mecanismo com o backend local
```

O ensaio restaura **numa base descartável** (`concordia_restore_<ts>`), confere tabelas,
`migrations` aplicadas e `migrate:status` **sem pendências**, imprime o **RTO** e remove a base.
Saída JSON: `{"event":"db.restore_drill", ... "rto_s":N}` — cole no `kd` como evidência.

### Restore de verdade (host perdido / banco corrompido)

1. `docker compose -p <projeto> up -d postgres` (volume novo, vazio).
2. Recriar a base: `psql -U <user> -d postgres -c 'CREATE DATABASE <db>;'`.
3. `pg_restore --no-owner --no-acl --single-transaction -U <user> -d <db> < db-<ts>.dump`.
4. `scripts/update_prod.sh --execute --no-pull` (código já no lugar; schema idempotente).
5. Conferir `GET /up` 200 e as páginas críticas (§6).
6. Se o dump local sumiu: baixar a cópia `s3://<bucket>/backups/db-<ts>.dump` (ADR-030).

### PITR (recuperação pontual) — implantado

O cluster já arquiva WAL: `docker/postgres/postgresql.conf` traz `wal_level = replica`,
`archive_mode = on`, `archive_timeout = 300` e `archive_command` copiando cada segmento para
`${PGDATA}/wal_archive` (mesma máquina, para funcionar em qualquer host). **Em produção**, aponte o
arquivamento para o storage S3 (ADR-030; `wal-g`/`archive_command` com `aws s3 cp`) — o arquivo local
não protege contra perda do host.

Ensaio (obrigatório antes de ir ao ar, e periódico depois):

```bash
tools/pitr-drill.sh              # base backup + WAL + recuperação até um LSN; prova que 'late' some
tools/pitr-drill.sh --keep       # mantém diretórios/log para inspeção
```

O ensaio cria uma base descartável, tira `pg_basebackup`, força o arquivamento (`pg_switch_wal`),
escreve uma transação **depois** do alvo e restaura com `recovery_target_lsn` (o alvo por LSN é
determinístico — por relógio erra por fuso/corrida do arquivador). Verifica que o estado é exatamente
o do alvo (`base,after`, **sem** `late`), promove e mede o **RTO**. Saída JSON:
`{"event":"db.pitr_drill", ..., "rto_s":N}` — cole no `kd`.

Recuperação de verdade: restaurar `pg_basebackup` do último base backup + `restore_command` apontando
para o arquivo de WAL + `recovery_target_time`/`_lsn` (ver log do ensaio); conferir as páginas
críticas (`tools/go-live-check.sh`) e promover.

### Retenção do WAL arquivado (G-14)

O `archive_command` não poda nada: sem política, `${PGDATA}/wal_archive` cresce até encher o disco.
A janela de PITR é a retenção — o `pg_dump` lógico **não** depende de WAL. Poda por idade:

```bash
tools/wal-retention.sh --dry-run --json   # lista o que sairia (evidência)
tools/wal-retention.sh --json             # remove além de WAL_RETENTION_DAYS (padrão 14)
```

- Cron diário no host (o script entra no container via Compose):
  `17 3 * * * cd /home/<acct>/public_html && tools/wal-retention.sh --json >> storage/logs/wal-retention.log 2>&1`.
  `--days 0` é recusado; `wal_archive` não inclui `pg_wal`.
- **Alerta:** `du -sh ${PGDATA}/wal_archive` > **20 GiB** ou disco livre < **15%** → reduzir
  `WAL_RETENTION_DAYS` para 7 e investigar `archive_timeout`/tráfego de escrita.
- Alternativa offsite: `wal-g`/`archive_command` para o S3 externo (ADR-030) — aí a retenção é
  do bucket e o arquivo local pode ser descartado.

### RTO/RPO acordados (medidos em dev, 2026-10-20)

| Cenário | RTO | RPO | Evidência |
|---|---|---|---|
| Banco corrompido (dump local validado) | **~9 s** | último dump (diário) | `tools/db-restore-drill.sh` |
| Erro de dados recente (PITR no mesmo host) | **8–24 s** | `archive_timeout` (≤5 min) | `tools/pitr-drill.sh` |
| **Perda do host** (offsite real configurado) | **≤ 1 h** (provisionar + restaurar) | último envio offsite | `--from-offsite` contra o endpoint externo |
| Ensaio do mecanismo (pull + restore do backend S3 local) | **~15 s** (6 s pull + 9 s restore) | último dump enviado | `--from-offsite --disk=s3`, 2026-10-20 |

Perda do host **sem** offsite real não tem RTO: o dado existe só no disco perdido. O go-live
só libera DR quando o item “offsite externo” acima estiver marcado; o ensaio local prova o
caminho (`pull` com checagem de tamanho → restore em base descartável), não a distância física.

## 4. Rollback

- Automático: falha em qualquer fase → `trap` roda `git reset --hard <commit-anterior>` (só sem
  `--allow-dirty`), `composer install --no-dev`, `optimize`, `USR2` no FPM.
- Manual (deploy subiu e o problema apareceu depois):

```bash
git log --oneline -5                       # escolha o commit bom
git checkout <commit-bom> && scripts/update_prod.sh --execute --no-pull --allow-dirty
```

> O banco **não** volta sozinho: `migrate` só aplica. Mudança destrutiva exige decisão humana com o
> dump de §2 em mãos. `down()` nunca apaga dados (ADR-017: renomeia para `backup_*`).

## 5. Borda (WHM/cPanel) — primeira instalação

1. **Domínio**: `portal.<cliente>` e `cms.<cliente>` (e `api.` se usado) apontando para o servidor.
2. **Docroot**: `public_html/public` (Laravel). O container **ignora** `.htaccess`/`.user.ini`
   (ADR-005): o vhost do WHM só faz proxy.
3. **ProxyPass** no vhost (Apache do WHM é um proxy burro — ADR-005):

   ```apache
   ProxyPreserveHost On
   ProxyPass        / http://127.0.0.1:8080/ retry=0 timeout=300
   ProxyPassReverse / http://127.0.0.1:8080/
   ```

4. **AutoSSL** (Let's Encrypt do cPanel) para cada domínio; TLS 1.3/HSTS no edge (ADR-020).
5. `TrustProxies`/`TrustHosts` com a sub-rede do host (spec 16) — sem isso IP/HTTPS/rate limit erram.
6. Confirmar: `curl -sSI https://<dominio>/up` → 200; `curl -sSI https://<dominio>/` → CSP do edge
   (o e2e `14-security-headers` é a prova de ponta a ponta).
7. **Sysctl do host (Valkey)**: o cache/sessão/fila do CMS vive no Valkey, e o Valkey avisa em
   cada boot se o overcommit de memória estiver desligado ("Memory overcommit must be enabled!
   Without it, a background save or replication may fail under low memory condition"):

   ```bash
   sysctl vm.overcommit_memory          # 1 = ok; 0 = o Valkey avisa a cada boot
   echo 'vm.overcommit_memory=1' | sudo tee /etc/sysctl.d/99-valkey.conf
   sudo sysctl --system
   ```

   O `vm.overcommit_memory` **não** é namespaced: o container não pode ajustá-lo (o Compose não
   tem `sysctls` para ele) — é ajuste do **host**. Sem ele, um *background save* (o AOF) pode
   falhar sob pressão de memória.

### Observabilidade no ar (o que loga e como correlacionar)

| Log | Onde | Formato | Credencial |
|---|---|---|---|
| **Acesso (edge)** | `docker compose logs web` | 1 linha JSON por request | `Cookie`/`Authorization`/`Set-Cookie` **apagados** pelo `filter` |
| **Aplicação** | `storage/logs/concordia-YYYY-MM-DD.log` | 1 linha JSON por evento | — (o formatador mascara PII) |
| **Cache/sessão/fila** | `docker compose logs valkey` | logfmt | `hide-user-data-from-log yes` (PII mascarada) |
| **Banco** | `docker compose logs postgres` | linha do PG | — (sem `log_connections`: só o diagnóstico) |

**Correlação edge↔app**: o `X-Request-ID` que o app emite na resposta (ADR-024) aparece na
linha de acesso do edge, no campo `resp_headers.request_id`, e no log do app, no campo
`request_id` — o mesmo id nos dois lados:

```bash
curl -sI -H 'X-Request-ID: req-debug-123' http://localhost:8080/ >/dev/null
docker compose logs web --tail=20 | grep 'req-debug-123'          # edge
grep 'req-debug-123' storage/logs/concordia-$(date +%F).log        # app
```

O **segredo do Valkey não está em nenhum `argv`**: nem no processo do servidor, nem no probe do
healthcheck (`ps aux | grep requirepass` ⇒ 0 ocorrências). A senha é renderizada num conf de
runtime dentro do container pelo `docker/valkey/entrypoint.sh` e fica no *environment* — que não
aparece no `ps`.

## 6. Primeira semana (go-live, ADR-042)

1. **Checklist mecânico** (páginas críticas + arquivos de raiz + cabeçalhos do edge):

   ```bash
   tools/go-live-check.sh                                  # usa BASE_URL / WEB_PORT
   tools/go-live-check.sh --url https://portal.exemplo.gov.br --json
   tools/go-live-check.sh --path /diario-oficial --path /licitacoes
   ```

   Sai != 0 se qualquer página/arquivo/cabeçalho falhar (é o registro do go-live). O relatório
   mostra o **status real** (`HTTP 404`, `HTTP 500`), não "sem resposta". a11y/axe e e2e continuam
   sendo o `make check E2E=1`.
2. `sitemap.xml`/`robots.txt`/`security.txt` com 200 e `ETag`. `INDEXNOW`/sitemap enviados.
3. **301s**: cadastrar à mão as URLs antigas que importam —

   ```bash
   php artisan seo:redirect --from=/noticias/antiga-url --to=/noticias/nova-url
   php artisan seo:redirect                      # lista
   php artisan seo:redirect --from=/x --disable  # desativa (volta o 404 real)
   php artisan seo:redirect --from=/x --remove   # remove
   ```

   A rota viva **nunca** é sombreada; destino externo/`//` é recusado e um redirect que volta para a
   origem (`/a → /a`, ou `/a → /b` com `/b → /a`) é **recusado** — seria loop no navegador. Toda
   escrita aparece na trilha de auditoria (ADR-035).
4. Revisar `http.not_found` (log JSON) diariamente na 1ª semana: cada caminho quente é um 301 que
   falta (ou uma página que não foi cadastrada).
5. Usuários: **sem UI e sem migração de identidade** (ADR-042) — recrie cada pessoa:

   ```bash
   # Prompt oculto: a senha não entra na linha de comando nem no histórico do shell.
   read -rsp 'Senha: ' PW; echo
   printf '%s' "$PW" | php artisan core:user:create pessoa@municipio.gov.br --name="Pessoa" --role=administrador --password-stdin
   unset PW

   php artisan core:user:create pessoa@municipio.gov.br --force --role=editor   # atualiza (prompt)
   php artisan core:user:create suporte@maxima.inf.br --name="Suporte" --role=suporte
   ```

   Argon2id (ADR-020), um perfil por usuário (ADR-033) e o perfil `suporte` só é aceito para
   `suporte@maxima.inf.br`. Sem `--password`/`--password-stdin` sem terminal, o comando gera e
   imprime **uma vez**. Sem `--role`, o usuário autentica mas não acessa nada (deny-by-default).
   Prefira o prompt ou `--password-stdin` ao `--password=` (este fica no histórico do shell); a
   senha exige **12 caracteres** nos dois caminhos. O evento de log segue a ação
   (`core.user_create`/`core.user_update`), sem e-mail nem senha.

## 7. Higiene

- Nunca `rm -rf`; descarte para `.pi/trash/`.
- Nunca commitar `storage/backups/` (dado pessoal) — já no `.gitignore`.
- Segredos só no `.env`/secret manager; o script nunca os imprime.
- Auditoria de quem operou: `storage/logs/deploy-*.log` (técnico) + trilha de auditoria (ADR-035)
  para o que passa pelo CMS.
