# Spec 026 — Purge VM112: validação Traefik pós-remoção (CT114)

**Criado:** 2026-06-19  
**Solicitado por:** Roger  
**Prioridade:** **P0** (incidente produção)  
**Status:** ✅ Implementado (VM112 + CT114, 2026-06-19)  
**Sistema:** Wizard VM112 · Traefik CT114 · Desk VM122  
**Relacionado:** Spec **017** (purge domínio) · Spec **025** (continuidade wizard) · Spec **018** (Serviços / drawer purge)

---

## Incidente que motivou a spec

**Data:** 2026-06-19 ~02:18 UTC  
**Sintoma:** `https://onboard.ligbox.com.br/onboard` → **404 page not found** (Traefik Go default), afectando **todos** os domínios onboard — não só o domínio purgado.

**Domínios purgados na sessão:** `iofficebooks.com`, `exuberanti.com.br`.

**Causa raiz:**

1. `_purge_traefik_routers()` em `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py` remove routers por **corte de texto** (`Host(...)` → próximo `\n    `).
2. Isso deixou bloco **`mail-mail-exuberanti-com-br-Router` sem `rule:`** e **chave duplicada** no `dynamic.yml`.
3. Traefik v3.6 **rejeitou o ficheiro inteiro**:
   ```
   yaml: unmarshal errors: mapping key "mail-mail-exuberanti-com-br-Router" already defined
   ```
4. Após restart, só **3 routers internos** activos (`acme`, `api`, `dashboard`) — zero rotas de produção.
5. O purge reportou **`traefik_ok`** porque validou apenas **SSH write + restart**, não carga efectiva da config.

**Correcção manual aplicada (19/06):** remoção de routers inválidos/duplicados + restart Traefik → 62 routers activos.

---

## Objetivo

Tornar o purge de domínio **seguro para a plataforma inteira**: após remover um tenant, o Traefik **tem de continuar operacional** e o onboard **tem de responder 200**.

**Regra de ouro (nova):**

> Purge só está **concluído** quando o domínio sumiu da base **e** o Traefik tem ≥ N routers **e** `GET https://onboard.ligbox.com.br/onboard` → **200** com HTML do wizard.

---

## Fora de escopo

- Reescrever Spec 017 (histórico Desk, RBAC, drawer)
- Purge parcial (só DNS, só contas)
- Validação de certificados LE por domínio purgado (opcional futuro)
- Automatizar purge agendado

---

## Problema na implementação actual (VM112)

| Função | Ficheiro | Problema |
|--------|----------|----------|
| `_purge_traefik_routers` | `domain_orchestration.py` | Corte textual frágil; não remove middleware `webmail-pending-{slug}`; não valida YAML |
| `_purge_traefik_sni` | idem | OK funcional; falta verificação pós-restart HAProxy |
| `_execute_purge` | idem | Marca `traefik_ok` sem smoke test |
| Portal users | `_purge_portal_users` | Só `/var/lib/ibytera-mail-portal/portal_users/` — **falta** `/var/lib/ligbox-wizard/portal_users/` |
| Nginx Carbonio | — | **Não** limpa vhosts `mail.{domain}` em `/opt/zextras/conf/nginx/includes/` |
| Branding / scripts deploy | — | **Não** remove entrada `tenant_branding.py` nem refs em `apply-admin-nginx-overrides.py` |

---

## Solução proposta

### Fase A — Remoção Traefik robusta (P0)

Substituir corte textual por script Python remoto no CT114 (mesmo padrão de `infrastructure.do_traefik()`):

1. **Backup** antes de editar:
   ```
   /root/traefik/dynamic.yml.bak-purge-{domain_slug}-{timestamp}
   ```
2. **Parse YAML** (`yaml.safe_load` para validação; **edição linha-a-linha** — nunca `safe_dump` no ficheiro inteiro).
3. Remover, por domínio:
   - Router `mail-mail-{slug}-Router` (e variantes)
   - Middleware `webmail-pending-{slug}` (redirect regex para wizard)
   - Qualquer router cujo `rule` contenha `Host(\`mail.{domain}\`)` ou alias mail
4. **Validação pré-restart:**
   - YAML parse OK
   - Zero chaves duplicadas em `http.routers`
   - Todo router tem campo `rule` não vazio
   - Zero ocorrências de `mail.{domain}` no texto (sanity grep)
5. **Restart** Traefik só se validação OK.
6. Se validação falhar → **rollback** do backup **sem** restart.

**Slug:** `{domain}` com `.` → `-` (ex.: `exuberanti.com.br` → `exuberanti-com-br`).

---

### Fase B — Verificação pós-purge (P0)

Novo step `_execute_purge`: **`traefik_validate`** (após `traefik_routers`).

| # | Check | Comando / origem | Critério |
|---|-------|------------------|----------|
| B1 | Routers carregados | `curl -s http://127.0.0.1:8080/api/http/routers` (CT114) | `count ≥ 10` (alerta se `< 10`; falha se `< 5`) |
| B2 | Onboard router activo | JSON routers | existe `onboard-ligbox-Router@file` enabled |
| B3 | Smoke HTTPS onboard | `curl -sf -o /dev/null -w '%{http_code}' https://onboard.ligbox.com.br/onboard` | `200` |
| B4 | Smoke API VM112 | `curl -sf -o /dev/null -w '%{http_code}' http://10.10.10.112:8090/onboard` | `200` |
| B5 | Sem refs domínio no dynamic | `grep -i {domain}` em `dynamic.yml` | 0 matches (excepto backup) |
| B6 | Log Traefik limpo | `docker logs traefik 2>&1 \| tail -20` | sem `unmarshal errors` / `invalid rule` nos últimos 30s |

**Falha em B1–B4:** rollback `dynamic.yml` + restart Traefik + step `traefik_validate` = **error** + job purge = **error** (não `done`).

**Timeline Desk:** novo passo visível «Validar Traefik / onboard» com detalhe de cada check.

---

### Fase C — Purge VM112 completo (P1)

Expandir `_execute_purge` com steps adicionais (ou sub-steps documentados):

| Step | Acção |
|------|--------|
| `portal_users_wizard_store` | Apagar JSON em `/var/lib/ligbox-wizard/portal_users/` cujo email ∈ domínio |
| `nginx_vhosts` | Remover `server_name mail.{domain}` de includes nginx Carbonio + `nginx -t` + reload |
| `tenant_branding` | Remover linha em `tenant_branding.py` |
| `deploy_scripts` | Remover `mail.{domain}` de `apply-admin-nginx-overrides.py` e `sync-traefik-admin-certs.sh` |
| `traefik_export_certs` | Apagar `mail-{slug}*.pem` em `/opt/zextras/ssl/letsencrypt/traefik-export/` |

Cada step reporta `ok` / `error` na timeline; falha nginx `nginx -t` → **error** (não deixa mail quebrado).

---

### Fase D — Desk / histórico (P2)

- Persistir em `vm112_json` do job: resultado de `traefik_validate` (checks B1–B6).
- Badge **error** no histórico se rollback Traefik ocorreu.
- Alerta ops (ntfy / webhook) quando purge falha em `traefik_validate`.

---

## Alterações de API / timeline

### VM112 — novos steps em `POST /api/admin/domains/{domain}/purge`

Ordem actualizada (trecho Traefik):

```
…
traefik_sni          → running → done|error
traefik_routers      → running → done|error   (Fase A — lógica nova)
traefik_validate     → running → done|error   (Fase B — NOVO)
…
```

**Resposta `result` (campos novos):**

```json
{
  "traefik_validate": {
    "ok": true,
    "router_count": 62,
    "onboard_http": 200,
    "rollback": false
  },
  "traefik_rollback": null
}
```

Em falha:

```json
{
  "traefik_validate": { "ok": false, "router_count": 3, "onboard_http": 404, "rollback": true },
  "traefik_rollback": "dynamic.yml.bak-purge-exuberanti-com-br-20260619T021800Z"
}
```

---

## Ficheiros a alterar

| VM | Ficheiro | Fase |
|----|----------|------|
| 112 | `backend/app/services/domain_orchestration.py` | A, B, C |
| 112 | `backend/app/services/infrastructure.py` | A (reutilizar `_router_key_for_host`, SSH helpers) |
| 114 | `/root/traefik/dynamic.yml` | _(runtime — só via purge script)_ |
| 122 | `api/app/vm112_domains_routes.py` | D (opcional — repassar novos campos) |
| 122 | `frontend/assets/app.js` | D (render checks no modal histórico) |

**Deploy:** VM112 `systemctl restart ligbox-wizard` após merge.

---

## Critérios de aceitação

1. Purge de domínio teste remove router/middleware **sem** duplicar chaves YAML.
2. Após purge, Traefik API reporta **≥ 10** routers HTTP.
3. `curl -sf https://onboard.ligbox.com.br/onboard` → **200** imediatamente após purge.
4. Purge com YAML inválido simulado → **rollback** automático + job status **error** (não `done`).
5. Portal users removidos de **ambas** as pastas (`ibytera-mail-portal` + `ligbox-wizard`).
6. Histórico Desk (Spec 017 v2) mostra step `traefik_validate` com detalhe.
7. Regressão: purge de domínio inexistente (`no_zone`, `domínio já ausente`) continua idempotente.

---

## Test plan (E2E)

```bash
# Pré: criar domínio teste via wizard (zona CF pending OK)
DOMAIN=teste-purge-$(date +%s).example.com  # ou domínio real de lab

# Executar purge
curl -s -X POST "http://10.10.10.112:8090/api/admin/domains/${DOMAIN}/purge?sync=true" \
  -H "X-Api-Key: $ADMIN_API_KEY" | jq '.result.traefik_validate'

# Validar plataforma
curl -sf -o /dev/null -w "onboard:%{http_code}\n" https://onboard.ligbox.com.br/onboard
ssh root@10.10.10.114 'curl -s http://127.0.0.1:8080/api/http/routers | python3 -c "import sys,json; print(len(json.load(sys.stdin)),\"routers\")"'
```

**Teste de regressão (incidente 19/06):** purge `exuberanti.com.br` duas vezes seguidas → segunda execução idempotente, Traefik estável.

---

## Riscos e mitigação

| Risco | Mitigação |
|-------|-----------|
| Rollback falha | Manter últimos 5 backups `dynamic.yml.bak-purge-*` |
| Traefik API :8080 fechado externamente | Checks só via SSH CT114 localhost |
| Purge longo (>60s) | Jobs async Spec 017 já existem; validate no final |
| Race: dois purges simultâneos | Lock file CT114 `/tmp/traefik-dynamic.lock` |

---

## Prioridade no backlog

| Fase | Prioridade | Motivo |
|------|------------|--------|
| **A + B** | **P0** | Evita outage total do onboard |
| **C** | P1 | Limpeza completa tenant (nginx, branding) |
| **D** | P2 | Observabilidade Desk |

---

## Referências

- Incidente: purge `exuberanti.com.br` 2026-06-19 — Traefik 3 routers only
- Spec 017 — ordem purge VM112 + histórico Desk
- Spec 025 — item backlog «Traefik YAML validation» (consolidar implementação aqui)
- Log Traefik: `mapping key "mail-mail-exuberanti-com-br-Router" already defined at line 475`
- Fix manual: `dynamic.yml.bak-fix-dup-exuberanti-20260619`

---

## Conclusão (estado actual)

| Fase | Entrega | Estado |
|------|---------|--------|
| A | Remoção YAML estruturada + backup/rollback | ✅ |
| B | `traefik_validate` + smoke onboard | ✅ |
| C | Purge nginx / branding / wizard store | ✅ (parcial — VM112) |
| D | Histórico Desk + alerta ops | 📋 |

**Implementação pendente em VM112** — esta spec documenta o backlog acordado com Roger (2026-06-19).
