# Migración apply-reseller-import: cPanel → HestiaCP + cross-VPS

> Postmortem + fix del script de aplicación de JSONs post-provisión.
>
> Fecha: 2026-05-14
> Detectado durante: primer disparo real de `bewpro:bulk-provision` con 13 tenants de la campaña leadhunter `campaigns/2`.

---

## Síntoma reportado

> "No se están provisionando con su JSON seedeando los contenidos."

13 tenants entraron al pipeline (Airtable `Pipeline_Status=Required`) tras correr `bewpro:bulk-provision`. El cron principal `process-airtable2.sh` provisionaba correctamente cada tenant (HestiaCP user + DB + Nginx + SSL + Laravel + seed canónico), pero el JSON específico de cada lead NO se aplicaba.

Los tenants live mostraban data placeholder genérica del demo `law-firm-digital`, no el contenido del lead.

---

## Causa raíz

El script `/root/scripts/apply-reseller-import.sh` (que aplica el JSON post-provisión) fue escrito para la convención **cPanel** vieja y nunca se actualizó cuando el provisionador migró a **HestiaCP** + múltiples VPS.

3 supuestos del script viejo, los 3 incorrectos para la nueva infra:

| Supuesto viejo (cPanel-only) | Realidad nueva (HestiaCP + cross-VPS) |
|---|---|
| Path: `/home/{user}/public_html/git-files/{user}/` | Path: `/home/{user}/web/{domain}/public_html/git-files/{user}/` (HestiaCP envuelve cPanel con `/web/{domain}/`) |
| Todo en VPS1 (cp + su local) | Tenants se reparten en VPS1/VPS2/VPS3 según balanceo |
| `su - {user}` funciona | HestiaCP deshabilita shell del user post-provisión (`nologin`) → "This account is currently not available" |

Adicionalmente, durante el fix se descubrieron 2 problemas operativos:

- **`scp` SFTP fail**: VPS3 (HestiaCP) no tiene `sftp-server` instalado. OpenSSH 9+ en VPS1 usa SFTP por default → "Connection closed", exit 127.
- **`storage/app/` puede no existir** todavía cuando el cron de imports llega — el provisionador no siempre lo crea antes.

---

## Fix aplicado (6 cambios al script)

### 1. Path HestiaCP correcto

```bash
# Antes (cPanel):
TENANT_DIR="/home/${CPANEL_USER}/public_html/git-files/${CPANEL_USER}"

# Después (HestiaCP):
DOMAIN="${SLUG}.bewpro.com"  # SLUG leído del Airtable record
TENANT_APP_DIR="/home/${CPANEL_USER}/web/${DOMAIN}/public_html/git-files/${CPANEL_USER}"
```

El doble `{CPANEL_USER}` no es typo — HestiaCP envuelve la estructura cPanel previa adentro de `/web/{domain}/`, preservando el subfolder por user.

### 2. Auto-discovery cross-VPS

```bash
LOCATION=""
if id "$CPANEL_USER" &>/dev/null; then
  LOCATION="local"
elif ssh -p "$VPS2_PORT" $SSH_OPTS root@"$VPS2_IP" "id ${CPANEL_USER}" &>/dev/null; then
  LOCATION="vps2"
elif ssh -p "$VPS3_PORT" $SSH_OPTS root@"$VPS3_IP" "id ${CPANEL_USER}" &>/dev/null; then
  LOCATION="vps3"
fi
```

Probamos VPS1 (local) → VPS2 → VPS3. El primero que tenga el user es donde vive el tenant. No depende de un campo Airtable que pueda estar mal sincronizado.

### 3. Wrappers local/remote

```bash
remote_exec() {
  case "$LOCATION" in
    local) bash -c "$1" ;;
    vps2)  ssh -p "$VPS2_PORT" $SSH_OPTS root@"$VPS2_IP" "$1" ;;
    vps3)  ssh -p "$VPS3_PORT" $SSH_OPTS root@"$VPS3_IP" "$1" ;;
  esac
}
```

Toda la lógica de copy + mysql + su se enruta a través de `remote_exec` / `remote_copy`, transparente respecto a dónde vive el tenant.

### 4. `scp -O` (legacy SCP protocol)

```bash
scp -O -P "$VPS3_PORT" ... "$src" root@"$VPS3_IP":"$dst"
```

Fuerza el protocolo legacy en lugar de SFTP. Necesario porque VPS3 (HestiaCP) no tiene `sftp-server` y OpenSSH 9+ usa SFTP por default.

### 5. `su -s /bin/bash` para bypassear nologin

```bash
SEED_CMD="su -s /bin/bash - ${CPANEL_USER} -c 'cd ${TENANT_APP_DIR} && php artisan bewpro:seed ...'"
```

El flag `-s` fuerza el shell ignorando el campo `/etc/passwd`. HestiaCP setea `nologin` post-provisión por seguridad — sin `-s` da "This account is currently not available".

### 6. `mkdir -p storage/app` + check del SCP

```bash
remote_exec "mkdir -p '${TENANT_APP_DIR}/storage/app' && chown ${CPANEL_USER}:${CPANEL_USER} '${TENANT_APP_DIR}/storage/app'"

if ! remote_copy "$STASH_FILE" "$TENANT_JSON_PATH"; then
  # marca Failed en Airtable y exit
fi
```

Asegura que el directorio existe antes del SCP, y aborta limpiamente si la copia falla (el script viejo seguía aunque `cp` fallara).

---

## Validación end-to-end

Tras el patch, dispamos el script manualmente contra el primer tenant del bulk (Ramiro Villalba, `recq0EsuSfGfdvnEh`, VPS3):

```
[reseller-import] Tenant location: vps3 | user=aboganeh | domain=abogado-derecho-penal-ramiro-v.bewpro.com
[reseller-import] JSON copiado a vps3:.../storage/app/incoming-import.json
[reseller-import] Clearing tables: services service_categories team_members team_categories posts post_categories references reference_categories faqs faqs_categories
[reseller-import] Ejecutando bewpro:seed en vps3:aboganeh...
[reseller-import] ✓ Content import OK — 180 records insertados
```

Y el `<title>` del sitio confirma que la data del lead se aplicó:

```html
<title>Abogado Derecho Penal Ramiro Villalba | Estudios Jurídicos en Videla Correa 743</title>
```

Después se reactivó el cron y procesó automáticamente los 5 siguientes (`abogaz9j`, `aboga6os`, `alber1pw`, `bytesixb`, `estudqjh`) sin intervención. **180 records seedeados por tenant**, en serie, ~1 por minuto.

---

## Arquitectura del flujo de provisión (post-fix)

```
┌─ FASE 1: Provisión con seed canónico ─────────────────────────────┐
│ Cron VPS1 process-airtable2.sh (cada 1 min)                       │
│   Por cada Pipeline_Status=Required:                              │
│     1. HestiaCP create user (puede ir a VPS1/VPS2/VPS3)           │
│     2. DB + Nginx + SSL                                           │
│     3. git clone cd-system                                        │
│     4. composer install + npm build                               │
│     5. migrate + bewpro:seed CANÓNICO (placeholder del core)      │
│     6. Pipeline_Status=On Development + Cpanel_User poblado       │
│     7. Email bienvenida                                           │
└────────────────────────────────────────────────────────────────────┘
                                ▼
┌─ FASE 2: Re-seed con JSON del lead ────────────────────────────────┐
│ Cron VPS1 apply-pending-imports.sh (cada 1 min)                   │
│   Por cada storage/app/reseller-imports/{record_id}.json:         │
│     1. Lookup Airtable → ¿Cpanel_User listo + status válido?      │
│     2. Si SÍ → llama apply-reseller-import.sh (este script):      │
│        a. Auto-detect VPS donde vive el user (id check)           │
│        b. SCP -O del JSON al tenant                               │
│        c. DELETE de tablas que el JSON va a reemplazar            │
│        d. su -s /bin/bash {user} → bewpro:seed del JSON           │
│        e. PATCH Airtable Import_Status=Completed                  │
│        f. rm stash file                                           │
└────────────────────────────────────────────────────────────────────┘
```

---

## Deploy

El fix se desplegó manualmente a VPS1 durante el incidente:

```bash
# Backup del original
cp /root/scripts/apply-reseller-import.sh /root/scripts/apply-reseller-import.sh.cPanel-bak-2026-05-14

# Upload nueva versión
scp /tmp/apply-reseller-import.sh vps1-claude:/root/scripts/apply-reseller-import.sh
ssh vps1-claude 'chmod +x /root/scripts/apply-reseller-import.sh && bash -n /root/scripts/apply-reseller-import.sh'
```

Workflow futuro: este archivo vive en VPS1 pero está versionado en el repo bajo `scripts/apply-reseller-import.sh`. Cuando se modifique:

1. Editar la copia del repo
2. `scp` a VPS1
3. Commit + push

No hay deploy automático para scripts de `/root/` — son ops manuales por su naturaleza privileged.

---

## Lecciones

- **Scripts de ops privilegiados deben estar en el repo** aunque vivan en `/root/`. Estar fuera del repo significa cero auditoría, cero diff, cero CI.
- **Convenciones de path en panel de hosting cambian**: HestiaCP envolvió la estructura cPanel pero el script viejo asumió que el path raíz también cambió. Hay que probar a fondo cuando se migra el panel.
- **SSH/SCP cross-host requiere `-O`** si los servers tienen versiones distintas de OpenSSH (server sin sftp-server + cliente OpenSSH 9+).
- **`su -` vs `su -s /bin/bash -`**: si el user destino tiene `nologin` por seguridad, sólo el flag `-s` funciona.
- **Auto-discovery > confiar en campo Airtable**: el script intenta `id <user>` en cada VPS hasta que uno responda. Evita problemas de sync entre Airtable y filesystem real.

---

## Referencias

- Script versionado: `scripts/apply-reseller-import.sh`
- Comando bulk: `app/Console/Commands/BulkProvisionProjects.php`
- Doc bulk: `docs/bewpro2.0/sprint-q2/bulk-provisioning.md`
- Backup VPS1: `/root/scripts/apply-reseller-import.sh.cPanel-bak-2026-05-14`
- Validación: 6/6 tenants del batch tuvieron title HTML del lead, no placeholder genérico.
