Actualiza toolkit operativo y documentación

This commit is contained in:
urieljareth
2026-09-10 20:53:50 -06:00
parent 3b7209dcc1
commit 714057bfc8
69 changed files with 6023 additions and 384 deletions
+6 -6
View File
@@ -94,7 +94,7 @@ Each rule lists **what**, **why**, and **how to verify**.
- **Why:** TLS is issued by Traefik via **DNS challenge** (Cloudflare API token), so it
works regardless of Cloudflare's "Always Use HTTPS". Do **not** assume HTTP-01 challenge
— that path is intentionally not used here
(see [`issue-coolify-static-app-deploy.md`](issue-coolify-static-app-deploy.md)).
(see [`2026-04-11-coolify-static-app-deploy.md`](incidentes/2026-04-11-coolify-static-app-deploy.md)).
- **Verify:**
```powershell
curl.exe -k -sSI https://<name>.urieljareth.org/
@@ -285,13 +285,13 @@ curl.exe -k -sSI https://<name>.urieljareth.org/
| Symptom | Root cause | Fix | Source |
|---------|-----------|-----|--------|
| App's domain returns **502 Bad Gateway** | `ports_exposes` doesn't match the real listening port (often `3000` vs `80`) | Set `ports_exposes` to the actual port before deploy | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
| 502 / no certificate on the app domain | Traefik couldn't get a cert via HTTP challenge | Environment uses **DNS challenge**; ensure FQDN is set and don't depend on HTTP-01 | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
| App reachable only at an ugly **UUID subdomain** | FQDN not set, Coolify auto-generated it | Set `fqdn` to `https://<name>.urieljareth.org` before first deploy | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
| App's domain returns **502 Bad Gateway** | `ports_exposes` doesn't match the real listening port (often `3000` vs `80`) | Set `ports_exposes` to the actual port before deploy | [2026-04-11-coolify-static-app-deploy.md](incidentes/2026-04-11-coolify-static-app-deploy.md) |
| 502 / no certificate on the app domain | Traefik couldn't get a cert via HTTP challenge | Environment uses **DNS challenge**; ensure FQDN is set and don't depend on HTTP-01 | [2026-04-11-coolify-static-app-deploy.md](incidentes/2026-04-11-coolify-static-app-deploy.md) |
| App reachable only at an ugly **UUID subdomain** | FQDN not set, Coolify auto-generated it | Set `fqdn` to `https://<name>.urieljareth.org` before first deploy | [2026-04-11-coolify-static-app-deploy.md](incidentes/2026-04-11-coolify-static-app-deploy.md) |
| App **won't start**, port conflict | Compose publishes `80`/`443` (owned by `coolify-proxy`) | Remove host port publishing; let Traefik route | [runbooks/baserow.md](runbooks/baserow.md) |
| App **can't reach its DB** | Used `localhost`, or DB is on a different network | Use the DB **service name**; for shared services join the `coolify` network | [runbooks/nextcloud.md](runbooks/nextcloud.md), [runbooks/baserow.md](runbooks/baserow.md) |
| Coolify **UI blank** when opening the app page | Cloudflare tunnel route order — `/app/*` captured `/application/...` | Operator fix: `/project/*` route must precede `/app/*` in the dashboard | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
| **WebSocket / terminal** drops, `tls: first record does not look like a TLS handshake` | Tunnel routes for ports `6001`/`6002` set to `https://` | Operator fix: those routes must be `http://` | [ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md](ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md) |
| Coolify **UI blank** when opening the app page | Cloudflare tunnel route order — `/app/*` captured `/application/...` | Operator fix: `/project/*` route must precede `/app/*` in the dashboard | [2026-04-11-coolify-static-app-deploy.md](incidentes/2026-04-11-coolify-static-app-deploy.md) |
| **WebSocket / terminal** drops, `tls: first record does not look like a TLS handshake` | Tunnel routes for ports `6001`/`6002` set to `https://` | Operator fix: those routes must be `http://` | [2026-04-11-cloudflare-tunnel-websocket-tls.md](incidentes/2026-04-11-cloudflare-tunnel-websocket-tls.md) |
> The last two are *operator/infrastructure* fixes (Cloudflare dashboard), not things the
> app developer changes — listed here so an agent recognizes the symptom and points the
+408
View File
@@ -0,0 +1,408 @@
# Tool index — catálogo canónico de herramientas
**Este es el índice único de todo lo ejecutable del repo.** Si buscas "qué script
uso para X", empieza aquí y no en los `TOOLS.md` de cada skill (esos son guías de
uso; este es el catálogo).
Verificado contra el host real el **2026-08-07**. Las firmas de parámetros se
extrajeron del AST de PowerShell, no a mano.
---
## 0. Cómo leer este índice
- **R/W** — `RO` = solo lectura, se puede ejecutar sin preguntar. `W` = muta
estado, **exige confirmación explícita del usuario antes de ejecutar**.
`RO/W` = depende de los parámetros (columna "notas" lo aclara).
- **Env** — variables que deben estar cargadas (`. .\.env.local.ps1`). Si faltan,
el script *lanza excepción*, no falla silenciosamente.
- Todo se ejecuta desde la raíz del repo, en PowerShell.
---
## 1. Los 6 gotchas que producen resultados silenciosamente incorrectos
Léelos antes de invocar nada. No son teóricos: los cuatro primeros se
verificaron el 2026-08-07 y el quinto el 2026-08-23. Cada uno rompe una tarea de
forma que *parece* haber funcionado.
### 1.1 `-Raw` significa lo OPUESTO en dos familias de wrappers
Hay dos convenciones incompatibles. Elegir mal no da error: da un resultado vacío.
| Wrapper | Sin `-Raw` (default) | Con `-Raw` |
|---|---|---|
| `coolify_skill\scripts\Invoke-CoolifyApi.ps1` | **string JSON** | **objetos PowerShell** |
| `scripts\Invoke-CloudflareApi.ps1` | **string JSON** | **objetos PowerShell** |
| `deploy_skill\scripts\Invoke-GitHubApi.ps1` | **objetos PowerShell** | `{Status, Headers, Body}` |
| `gitea_skill\scripts\Invoke-GiteaApi.ps1` | **objetos PowerShell** | `{Status, Headers, Body}` |
Consecuencia práctica: en Coolify y Cloudflare, **si vas a filtrar o proyectar el
resultado necesitas `-Raw`**. Sin él recibes un `System.String` y
```powershell
# MAL: devuelve una fila vacía, sin error. El resultado es un string.
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/resources" |
Select-Object name, uuid
# BIEN
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/resources" -Raw |
Select-Object name, uuid
```
Usa el default (string JSON) solo cuando vas a mostrar la respuesta tal cual.
### 1.2 `Invoke-ProxmoxSsh.ps1` corrompe las comillas anidadas
`Invoke-ProxmoxSshCommand` pasa `$Command` como un único argumento a `ssh`, y
PowerShell 5.1 destroza las comillas embebidas al invocar un ejecutable nativo.
Cualquier comando con quoting anidado —típicamente `docker exec ... bash -lc '...
psql -c "SELECT ..."'`— llega mutilado al host:
```
bash: line 1: -c: command not found
psql: option requires an argument -- 'F'
```
**Solución verificada: codifica el comando remoto en base64.** Es el patrón a
usar para cualquier cosa con más de un nivel de comillas:
```powershell
$remote = @'
pct exec 102 -- docker exec -i postgres-c11xzy2tx2cdapm32f5b89vy bash -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT name FROM public.installation_configs"'
'@
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($remote))
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "echo $b64 | base64 -d | bash 2>&1"
```
El here-string `@'...'@` (comillas simples) es obligatorio: evita que PowerShell
expanda `$POSTGRES_PASSWORD` del lado de Windows.
Para comandos de un solo nivel de comillas (`pct list`, `docker ps --format
'{{.Names}}'`) el wrapper directo funciona bien.
### 1.3 Los nombres de contenedor NO se pueden adivinar
Coolify nombra cada contenedor `<servicio>-<uuid>` (y a veces le añade un sufijo
numérico de build). No existe un contenedor llamado `chatwoot` ni `nextcloud`:
```
chatwoot-c11xzy2tx2cdapm32f5b89vy
nextcloud-db-hdcdpkm0jko3qqvn5683ercc
web-instademo0portal0insta0demo1-060825532589
```
**Siempre resuelve el nombre real antes de usarlo.** Dos caminos:
```powershell
# a) desde Docker, por patrón
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker ps --format '{{.Names}}' | grep -i chatwoot"
# b) desde Coolify, para obtener el uuid del recurso (y de ahí el sufijo)
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/resources" -Raw |
Where-Object { $_.name -match 'chatwoot' } | Select-Object name, uuid, fqdn
```
Corolario: **un redeploy puede recrear el contenedor con otro sufijo**, y cualquier
script o cron que tenga el nombre hardcodeado empieza a fallar. Es exactamente lo
que le pasó al guard de Chatwoot (ver [runbooks/chatwoot-update.md](runbooks/chatwoot-update.md)).
### 1.4 `/applications/*` da 404 — por Cloudflare, no por Coolify (corregido 2026-08-29)
**Re-verificado a fondo el 2026-08-29 sobre v4.3.14 y el diagnóstico anterior
cambió:** el 404 NO lo produce Coolify. Es un bloqueo del edge de Cloudflare en
el hostname público. Mismo token, misma ruta:
| Llamada | Resultado |
|---|---|
| `https://coolify.urieljareth.org/api/v1/applications` (vía Cloudflare) | **404** |
| `http://192.168.0.117:8000/api/v1/applications` (origen, LXC 102) | **200** |
| `/github-apps` | igual: 404 vía CF, 200 vía origen |
| `/version`, `/resources`, `/services`, `/databases`, `/projects`, `/servers`, `/teams`, `/deployments`, `/security/keys` | OK por ambas vías |
La API del origen está **completa**: el `openapi.yaml` del contenedor
(`/var/www/html/openapi.yaml`) declara todo el namespace de `/applications/*`,
notificaciones, proveedores cloud y MCP, y responde. **Para esos endpoints,
apunta la llamada al origen** (`$env:COOLIFY_API_URL_ORIGIN`) o corrige la regla
de Cloudflare en el dashboard. Además, desde v4.2 los endpoints de estado
exigen **POST** (`GET /deploy` → 405; ver notas §10.1).
Esto re-habilita (previa verificación en el próximo deploy) herramientas que
estaban marcadas rotas — ver §4.
**Nota (2026-08-24):** `POST /services` **sí funciona** para stacks compose
propios, con tres condiciones que la API no perdona: **no** enviar `type` junto a
`docker_compose_raw`, mandar el compose en **base64**, y enviar el body como
**bytes UTF-8**. Las tres estaban mal en el toolkit y ya están corregidas; el
detalle está en
[docs/casos/firecrawl-stack-minimo.md §4](casos/firecrawl-stack-minimo.md).
`Test-PreDeployChecklist.ps1` sigue sin leer `docker-compose.coolify.yml`
(solo mira `docker-compose.yml|yaml` y `compose.yml|yaml`), así que **puede dar
todo PASS sobre un archivo que no abrió**.
### 1.5 "Verde en Coolify" NO significa "enrutado en Traefik"
Verificado el 2026-08-23. Es la causa de que un servicio recién creado desde la
librería de Coolify devuelva **`503 no available server`** estando en verde.
Son dos señales distintas y la UI solo muestra una:
| Señal | Quién la usa | Qué significa |
|---|---|---|
| `State.Status = running` | **La UI de Coolify** (el punto verde) | El contenedor existe y no ha muerto |
| `State.Health.Status = healthy` | **Traefik** | El contenedor entra al balanceador |
Traefik solo enruta contenedores que Docker reporta `healthy`. Un contenedor
`running` + `unhealthy` **no tiene ruta**, la petición cae al catch-all de
Coolify (`default_redirect_503.yaml`: `priority: -1000`, servicio `noop` con
`servers: { }`) y de ahí sale el string `no available server`.
En este host el primer boot de un servicio tarda **minutos** (rootfs ext4 sobre
loopback sobre HDD, ~39 ms por escritura), pero **12 de los 14 servicios no
tienen `start_period`** en su healthcheck. Se les declara `unhealthy` mucho antes
de que la app llegue a escuchar.
**Distingue el código HTTP antes de tocar nada:**
- **`502 Bad Gateway`** → Traefik *tiene* la ruta, el backend rechaza. Problema
de la app o del puerto.
- **`503 no available server`** → Traefik **no tiene** la ruta. Casi siempre es
un contenedor que todavía está arrancando. **Espera, no redeployes**: un
redeploy reinicia el entrypoint desde cero y reinicia el arranque lento.
```powershell
# El diagnóstico correcto, en un comando (solo lectura):
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
```
Detalle completo, evidencia e hipótesis descartadas:
[docs/casos/coolify-servicio-nuevo-503-no-available-server.md](casos/coolify-servicio-nuevo-503-no-available-server.md).
### 1.6 Si la imagen no expone el puerto donde sirve, el FQDN necesita `:puerto`
Verificado el 2026-08-24 diagnosticando `grimmory`, que devolvía **502**.
Coolify deriva el label `traefik.http.services.*.loadbalancer.server.port` del
**puerto que lleve el FQDN guardado** en `service_applications.fqdn`. Si el FQDN
no lleva puerto, Coolify **no emite el label**, y Traefik cae al único puerto que
declare la imagen (`Config.ExposedPorts`).
Eso funciona por accidente cuando app e imagen coinciden, y falla en silencio
cuando no:
| Servicio | FQDN guardado | Sirve en | Imagen expone | Resultado |
|---|---|---|---|---|
| `nextcloud` | sin puerto | 80 | 80 | OK por coincidencia |
| `n8n` | `…:5678` | 5678 | 5678 | OK explícito |
| `qdrant` | `…:6333` | 6333 | 6333 | OK explícito |
| **`grimmory`** | **sin puerto** | **80** | **6060** | **502** |
`grimmory` corría `healthy` (su healthcheck prueba `http://127.0.0.1/health`, o
sea el 80, y pasaba), Traefik lo enrutaba, y llegaba al 6060 donde no hay nada:
**connection refused → 502**.
Ojo con la confusión: tener `SERVICE_URL_GRIMMORY_80` en el compose **no basta**.
Lo que manda es el puerto en el FQDN almacenado.
**Cómo distinguirlo de otros fallos:**
- **`502`** → hay ruta, el backend rechaza. Compara el puerto donde escucha la app
con el que busca Traefik. Casi siempre es esto.
- **`503 no available server`** → no hay ruta (§1.5).
```powershell
# Puertos donde escucha de verdad (en hex; 0050 = 80, 1F90 = 8080):
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> sh -c 'cat /proc/net/tcp'"
# Puerto que busca Traefik (si no sale nada, cae al ExposedPorts de la imagen):
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker inspect <cont> --format '{{json .Config.Labels}}'"
```
**Arreglo:** poner el puerto en el FQDN (`https://x.urieljareth.org:80`) y
**redeployar** el servicio — los labels solo se regeneran al recrear el
contenedor.
---
## 2. Infraestructura: host Proxmox, red, apps del host
Todo llega al host por un solo camino:
```
PowerShell → Invoke-ProxmoxSshCommand → ssh [email protected]
→ pct exec 102 -- docker ... (para cualquier cosa de Docker)
```
| Script | R/W | Env | Parámetros | Qué hace |
|---|---|---|---|---|
| [scripts/ProxmoxAgent.ps1](../scripts/ProxmoxAgent.ps1) | librería | — | — | **Dot-source obligatorio** (`. .\scripts\ProxmoxAgent.ps1`). Expone `Get-ProxmoxConfig`, `Assert-ProxmoxConfig`, `Invoke-ProxmoxSshCommand`, `Invoke-ProxmoxApi`. Todos los demás scripts lo consumen; no reimplementes la conexión. |
| [scripts/Test-ProxmoxConnection.ps1](../scripts/Test-ProxmoxConnection.ps1) | RO | opcional `PROXMOX_API_TOKEN_*` | — | Smoke test: config + SSH + muestra de Docker + auth de API. Sale 1 si algo falla. **Ejecútalo antes de trabajo operativo.** |
| [scripts/Get-ProxmoxInventory.ps1](../scripts/Get-ProxmoxInventory.ps1) | RO | — | — | Snapshot completo: host, LXC, QEMU, Docker en LXC 102. |
| [scripts/Invoke-ProxmoxSsh.ps1](../scripts/Invoke-ProxmoxSsh.ps1) | **RO/W** | — | `-Command <string>` | Comando arbitrario por SSH. **El R/W lo determina el comando**: `pct list` es RO, `pct stop` es W. Ver gotcha §1.2 para comillas anidadas. |
| [scripts/Invoke-CloudflareApi.ps1](../scripts/Invoke-CloudflareApi.ps1) | **RO/W** | `CLOUDFLARE_API_TOKEN` | `-Method` `-Path` `-BodyJson` `-Raw` | API de Cloudflare (túnel + DNS). `GET` es RO; el resto W. `-Raw` → objetos (§1.1). |
| [scripts/Install-CoolifyAutostart.ps1](../scripts/Install-CoolifyAutostart.ps1) | **W** (RO con `-VerifyOnly`) | — | `-VerifyOnly` `-SkipOnboot` `-RunNow` `-Uninstall` | Instala el auto-arranque del stack tras corte de luz (LXC 102 + túnel). Usa `-VerifyOnly` para auditar sin tocar nada. |
**API REST de Proxmox** (distinta de la de Coolify): requiere
`PROXMOX_API_TOKEN_ID` + `PROXMOX_API_TOKEN_SECRET`. `Invoke-ProxmoxApi` lanza
excepción si faltan.
> ⚠️ **Estado actual (2026-08-07):** `.env.local.ps1` **no** define
> `PROXMOX_API_TOKEN_ID`/`_SECRET`, `CLOUDFLARE_API_TOKEN` ni
> `COOLIFY_EMAIL`/`COOLIFY_PASSWORD`. Las tres rutas que dependen de ellas
> (API de Proxmox, API de Cloudflare, flujo UI de Coolify) **fallan hoy**. Lo
> que sí está cargado: `PROXMOX_HOST/NODE/USER/SSH_KEY/COOLIFY_LXC`,
> `COOLIFY_API_URL`, `COOLIFY_TOKEN`, `GITHUB_TOKEN`, `GITHUB_OWNER`,
> `GITEA_URL/USER/TOKEN`.
### 2.1 Chatwoot — parche enterprise
Contexto completo en [runbooks/chatwoot-update.md](runbooks/chatwoot-update.md).
| Script | R/W | Parámetros | Qué hace |
|---|---|---|---|
| [scripts/Get-ChatwootLicenseStatus.ps1](../scripts/Get-ChatwootLicenseStatus.ps1) | RO | `-ServiceUuid` `-AppContainer` `-DbContainer` `-Deep` | Estado de la licencia. `-Deep` verifica además los feature flags por cuenta (`accounts.feature_flags`). **Usa esto para diagnosticar; nunca el parche.** |
| [scripts/Apply-ChatwootEnterprisePatch.ps1](../scripts/Apply-ChatwootEnterprisePatch.ps1) | **W** | `-DryRun` `-ReenableAccountFeatures` `-ServiceUuid` `-Container` `-AppContainer` `-LxcId` `-ProxmoxHost` `-SshKey` | Reaplica el parche. **El SQL de 3 filas no basta**: pasa siempre `-ReenableAccountFeatures`. Usa `-DryRun` primero. |
| [scripts/chatwoot-enterprise-guard.sh](../scripts/chatwoot-enterprise-guard.sh) | — | — | **Copia versionada** del guard que corre en el host. No se ejecuta desde Windows. Instalado en `/root/scripts/chatwoot-enterprise-guard.sh`, agendado por `/etc/cron.d/chatwoot-enterprise-guard` cada 5 min. Log: `/var/log/chatwoot-enterprise-guard.log` (solo escribe cuando actúa). |
### 2.2 Artefactos que viven en el host (no se invocan desde Windows)
| Archivo | Qué es |
|---|---|
| [scripts/host/coolify-autostart.sh](../scripts/host/coolify-autostart.sh) | Script de arranque; lo despliega `Install-CoolifyAutostart.ps1`. |
| [scripts/host/coolify-autostart.service](../scripts/host/coolify-autostart.service) | Unit de systemd correspondiente. |
| [scripts/fix-nextcloud-config.php](../scripts/fix-nextcloud-config.php) | Fragmento puntual del fix HTTPS de Nextcloud. Ver [runbooks/nextcloud.md](runbooks/nextcloud.md). |
| [scripts/Set-ProxmoxEnv.example.ps1](../scripts/Set-ProxmoxEnv.example.ps1) | Plantilla de entorno. El template completo es [.env.example](../.env.example). |
### 2.3 Scripts de deploy específicos de una app
`scripts/apps/` guarda scripts one-off con uuid y dominio **hardcodeados**. Son
**mutantes** y están atados a un recurso concreto: lee la cabecera antes de
ejecutar uno, y verifica que el uuid siga siendo el correcto (§1.3).
| Script | R/W | Env | Qué hace |
|---|---|---|---|
| [scripts/apps/Deploy-SoloLeveling.ps1](../scripts/apps/Deploy-SoloLeveling.ps1) | **W** | `COOLIFY_*`, `PROXMOX_*`, `GITHUB_TOKEN` (scope `repo`) | Re-deploy de "El Sistema (Solo Leveling)" sin depender del pull de GHCR (el registry es privado y el token no tiene `read:packages`). Construye la imagen **en el servidor** (clone del repo privado + `docker build`), la taguea con el nombre que espera el compose generado por Coolify, y levanta el servicio. Params: `-ServiceUuid` `-Domain` `-Repo` `-Image` `-NoBuild`. Requiere que el service ya esté registrado en Coolify. |
| [scripts/apps/Deploy-OhDaddy.ps1](../scripts/apps/Deploy-OhDaddy.ps1) | **W** | `COOLIFY_*`, `PROXMOX_*` | Redeploy de oh-daddy (stack app+db+Inngest self-hosted, servicio `rzittzudkunwx8gilonn7tqe`). Clone del repo público + `stacks/oh-daddy/Dockerfile` inyectado, build de `oh-daddy-app:local` en el server, `compose up -d`, schema idempotente y re-registro de funciones Inngest (`PUT /api/inngest`). Params: `-ServiceUuid` `-Fqdn` `-Repo` `-Image` `-NoBuild`. Detalle: [casos/oh-daddy-deploy.md](casos/oh-daddy-deploy.md). |
---
## 3. Coolify — operación
| Script | R/W | Env | Parámetros | Qué hace |
|---|---|---|---|---|
| [coolify_skill/scripts/Get-CoolifyDockerStatus.ps1](../coolify_skill/scripts/Get-CoolifyDockerStatus.ps1) | RO | — | `-Filter <regex>` `-All` | Estado de contenedores vía LXC 102. `-All` lista todo; `-Filter` acota por regex. Primera parada para "¿está corriendo X?". |
| [coolify_skill/scripts/Test-CoolifyServiceReady.ps1](../coolify_skill/scripts/Test-CoolifyServiceReady.ps1) | RO | — | `-Uuid <uuid>` `-Fqdn <url>` `-WaitSeconds <n>` | Contrasta `running` vs `healthy` por contenedor (la discrepancia que la UI esconde), avisa de `start_period` insuficiente, detecta setup en curso (`chown`/`apt`) y prueba el dominio distinguiendo 503 de 502. **Primera parada para un `503 no available server`** — ver §1.5. |
| [coolify_skill/scripts/Set-CoolifyHealthcheckGrace.ps1](../coolify_skill/scripts/Set-CoolifyHealthcheckGrace.ps1) | **RO/W** | — | `-Uuid <uuid>` `-StartPeriodSeconds 300` `-MinIntervalSeconds 10` `-ShowResult` `-Apply` | Inserta `start_period` en los healthcheck que no lo tienen y sube intervalos demasiado cortos, editando `services.docker_compose_raw`. **Dry-run sin `-Apply`.** Con `-Apply` guarda copia de rollback en `backups/` y verifica leyendo de vuelta. **No redeploya** — el healthcheck solo aplica al recrear el contenedor. Ver §1.5. |
| [coolify_skill/scripts/Invoke-CoolifyApi.ps1](../coolify_skill/scripts/Invoke-CoolifyApi.ps1) | **RO/W** | `COOLIFY_TOKEN` | `-Method` `-Path` `-BodyJson` `-Raw` | API de Coolify. `GET` RO; `POST/PUT/PATCH/DELETE` W → confirmación. Ver §1.1 (`-Raw`) y §1.4 (endpoints que dan 404). |
| `coolify_skill/scripts/coolify.sh` | RO/W | `COOLIFY_TOKEN` | — | Helper Bash legacy para sesiones Linux/WSL. En este repo se prefieren los wrappers PowerShell. |
**Referencia de la API:** no cargues el árbol completo. Busca y abre un solo
archivo:
```powershell
rg -n "deploy|database|environment" .\coolify_skill\references
Get-Content .\coolify_skill\references\ops\deploy-by-tag-or-uuid.md
```
---
## 4. Deploy de proyectos nuevos a Coolify
> ⚠️ **Lee esto antes de usar cualquier cosa de esta sección.** El pipeline
> "una sola línea" fallaba porque `/applications/*` daba 404 (§1.4). Desde el
> 2026-08-29 sabemos que ese 404 es de **Cloudflare, no de Coolify**: apuntando
> `COOLIFY_API_URL` al origen (`http://192.168.0.117:8000/api/v1`) el namespace
> completo responde. Scripts marcados ❌ abajo deben funcionar así — pendiente de
> verificar en el próximo deploy; el flujo UI y la vía DB siguen como fallback.
| Script | R/W | Env | Estado en esta instancia |
|---|---|---|---|
| [Publish-ProjectToCoolify.ps1](../deploy_skill/scripts/Publish-ProjectToCoolify.ps1) | W | `GITHUB_TOKEN`, `COOLIFY_TOKEN` | ⚠️ Su paso 5 invoca `New-CoolifyApplication.ps1` → probar contra el origen. |
| [New-CoolifyApplication.ps1](../deploy_skill/scripts/New-CoolifyApplication.ps1) | W | `COOLIFY_TOKEN` | ⚠️ Usa `POST /applications/public` y `PATCH /applications/{uuid}` → funcionan vía origen (404 solo vía Cloudflare). |
| [Invoke-CoolifyRollback.ps1](../deploy_skill/scripts/Invoke-CoolifyRollback.ps1) | W | `COOLIFY_TOKEN` | ⚠️ Usa `PATCH /applications/{uuid}` → funciona vía origen. |
| [New-CoolifyService.ps1](../deploy_skill/scripts/New-CoolifyService.ps1) | W | `COOLIFY_TOKEN` | ✅ **Funciona.** Usa `POST /services`. Es la vía válida para stacks multi-contenedor. |
| [New-CoolifyAppViaDB.ps1](../deploy_skill/scripts/New-CoolifyAppViaDB.ps1) | **W (INSERT directo en la DB)** | — | ⚠️ **Último recurso.** Escribe la fila en `applications` de la DB de Coolify saltándose API y UI. Sin validación ni rollback. Requiere `-EnvironmentId` y `-GithubRepoId` reales. |
| `coolify-ui/coolify-login.mjs` + `Configure-CoolifyComposeApp.mjs` | W (vía navegador) | `COOLIFY_EMAIL`, `COOLIFY_PASSWORD` | ✅ Fallback soportado para apps git build-from-source. Credenciales probables ya en `.env.local.ps1` (sin verificar). |
**Vías, en orden de preferencia:**
1. Stack multi-contenedor con `docker-compose.coolify.yml` → `New-CoolifyService.ps1` (API).
2. App git build-from-source → API contra el origen; si falla, flujo UI con Playwright (`coolify-ui/`).
3. Último recurso → `New-CoolifyAppViaDB.ps1`.
Detalle completo del flujo v4.1.2 y sus trampas (BOM UTF-8 rompe el parser YAML
de Coolify; "Reload Compose File" es obligatorio; fijar dominios por servicio o
sale 503; no encolar deploys concurrentes) en
[deploy_skill/references/coolify-4.1.2-notes.md](../deploy_skill/references/coolify-4.1.2-notes.md).
### 4.1 Scripts de deploy que funcionan sin depender de `/applications`
| Script | R/W | Env | Parámetros | Qué hace |
|---|---|---|---|---|
| [Initialize-CoolifyProject.ps1](../deploy_skill/scripts/Initialize-CoolifyProject.ps1) | W (solo local) | — | `-Path` `-Stack {node\|python\|compose\|static}` `-AppPort` `-Force` | Genera Dockerfile/compose/.dockerignore compatibles. Idempotente: no sobreescribe sin `-Force`. |
| [Test-PreDeployChecklist.ps1](../deploy_skill/scripts/Test-PreDeployChecklist.ps1) | RO | — | `-Path` `-ExpectedPort` `-Strict` | Valida el proyecto contra las reglas duras de [AGENTS-coolify-apps.md](AGENTS-coolify-apps.md). **Gate obligatorio antes de cualquier push.** |
| [New-GitHubRepo.ps1](../deploy_skill/scripts/New-GitHubRepo.ps1) | W (remoto) | `GITHUB_TOKEN` | `-Name` `-Description` `-Private` `-Owner` | Crea el repo en GitHub. Idempotente: avisa si ya existe. |
| [Invoke-GitHubApi.ps1](../deploy_skill/scripts/Invoke-GitHubApi.ps1) | RO/W | `GITHUB_TOKEN` | `-Method` `-Path` `-BodyJson` `-Raw` | API de GitHub. Default → objetos (§1.1). |
| [Test-PostDeploy.ps1](../deploy_skill/scripts/Test-PostDeploy.ps1) | RO | `COOLIFY_TOKEN` (opcional) | `-Fqdn` `-ContainerName` `-SiblingService` `-ApplicationUuid` | Verifica el estado en Coolify: contenedores, logs del proxy, DNS entre hermanos. `-ApplicationUuid` consulta `/applications/*` → vía pública da 404 (Cloudflare), vía origen funciona. |
| [Test-ServiceOnline.ps1](../deploy_skill/scripts/Test-ServiceOnline.ps1) | RO | — | `-Fqdn` `-Path` `-ExpectedStatusCode` `-ExpectTitle` `-Screenshot` `-SkipBrowser` `-TimeoutMs` `-Retries` | **La "definición de done".** Dos capas: HTTP 200 vía curl + render real en Chromium (Playwright). Detecta 502 de Traefik, TLS a medias y crashes de JS del cliente. Sin Node/Playwright la capa de navegador se omite con warning, no falla. |
| `deploy_skill/scripts/verify-online.mjs` | RO | — | (invocado por el script de arriba) | Capa de navegador de `Test-ServiceOnline.ps1`: navegación real con Playwright, captura `pageerror` y requests fallidos del main frame. No lo invoques directo. Requiere `npm install` en la raíz del repo. |
`git push` usa **Windows Credential Manager (wincred)**, no el PAT. Verificado
para la cuenta `urieljarethbusiness-cpu`.
**Plantillas:** [deploy_skill/references/templates/](../deploy_skill/references/templates/) —
`Dockerfile.node`, `Dockerfile.python`, `docker-compose.app-db.yml`,
`env.local.template.ps1`.
---
## 5. Gitea — hosting git self-hosted
Distinto de deploy_skill: esto administra la capa de hosting git, no Coolify.
El propio repo Manager vive aquí.
| Script | R/W | Env | Parámetros | Qué hace |
|---|---|---|---|---|
| [Test-GiteaConnection.ps1](../gitea_skill/scripts/Test-GiteaConnection.ps1) | RO | `GITEA_URL`, `GITEA_TOKEN` | — | Smoke test: `/version`, `/user`, `/settings/api`, `/repos/search`. Sale 1 si algo falla. |
| [Get-GiteaRepo.ps1](../gitea_skill/scripts/Get-GiteaRepo.ps1) | RO | `GITEA_URL`, `GITEA_TOKEN` | `-Owner` `-Name` `-Search` `-List` | Lee, lista o busca repos. `-Owner` default = usuario del token. |
| [Invoke-GiteaApi.ps1](../gitea_skill/scripts/Invoke-GiteaApi.ps1) | RO/W | `GITEA_URL`, `GITEA_TOKEN` | `-Method` `-Path` `-BodyJson` `-Raw` | API de Gitea. Usa `curl.exe --data-binary` con UTF-8 sin BOM (el parser de Gitea se rompe con BOM). Default → objetos (§1.1). |
| [New-GiteaRepo.ps1](../gitea_skill/scripts/New-GiteaRepo.ps1) | W | `GITEA_URL`, `GITEA_TOKEN` | `-Name` `-Description` `-Private` `-Owner` `-NoAutoInit` | Crea repo (idempotente). `-NoAutoInit` evita el README del lado servidor para poder empujar historia local sin conflicto. |
| [Sync-GiteaRemote.ps1](../gitea_skill/scripts/Sync-GiteaRemote.ps1) | W | `GITEA_URL`, `GITEA_TOKEN` | `-AppPath` `-Name` `-Owner` `-RemoteName` `-Branch` `-CreateIfMissing` `-Private` `-Force` | Cablea el remoto y hace push headless. **El token nunca toca el disco**: va en un `http.extraHeader` de un solo uso, no en `.git/config`. `-RemoteName gitea` (default) convive con un `origin` de GitHub. |
---
## 6. Reglas de seguridad (no negociables)
1. **Read-only primero.** El default es diagnosticar: list, status, logs, inspect,
health checks.
2. **Confirmación explícita antes de cualquier cambio de estado.** Aplica a:
`pct`/`qm` start/stop/reboot/destroy; `docker` restart/stop/rm/compose up-down;
deploys de Coolify y cualquier `POST`/`PUT`/`PATCH`/`DELETE`; escritura de
variables de entorno; y todo cambio de firewall, red, storage, volumen, clave
o token. Antes de una acción riesgosa: captura el estado actual y enuncia el
camino de rollback.
3. **Nunca escribas secretos en el repo.** Ni tokens, ni passwords, ni claves
privadas, ni cookies, ni secretos de token PVE — no en Markdown, no en
scripts, no en logs. Viven solo en `.env.local.ps1` (gitignored) o en el
almacén de secretos del SO. Al depurar bases de datos, verifica conectividad
sin imprimir credenciales.
4. **Prefiere los scripts del repo** antes que cadenas de comandos manuales
largas, y no construyas comandos destructivos amplios a partir de strings
generados.
5. `-Force` existe en varios scripts para saltarse los prompts. **Úsalo solo
después de que el usuario haya aprobado el plan concreto.**
---
## 7. Dónde está el resto del contexto
| Qué necesitas | Dónde |
|---|---|
| Arquitectura + router de intención | [CLAUDE.md](../CLAUDE.md) |
| Topología verificada (fuente de verdad del estado) | [proxmox-inventory.md](proxmox-inventory.md) |
| Procedimientos concretos | [runbooks/](runbooks/) |
| Casos resueltos paso a paso | [casos/](casos/) |
| Incidentes archivados | [incidentes/](incidentes/) |
| Contrato para *construir* una app deployable | [AGENTS-coolify-apps.md](AGENTS-coolify-apps.md) |
| Reglas operativas por dominio | `agent/SKILL.md`, `coolify_skill/SKILL.md`, `deploy_skill/SKILL.md`, `gitea_skill/SKILL.md` |
| Guías de uso por dominio (ejemplos ejecutables) | los `TOOLS.md` de cada carpeta `*_skill/` |
+49 -9
View File
@@ -1,7 +1,22 @@
# Caso: Parche enterprise en Chatwoot (Coolify + LXC 102)
> Documentacion de caso verificada el 2026-06-16 desde esta maquina.
> Dominio: `https://chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org`
> Dominio: **`https://chat.urieljareth.org`** (corregido el 2026-07-24; el FQDN
> `chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org` que decia antes ya no aplica).
> **Leer antes de usar este caso (revision 2026-07-24):**
>
> 1. **El parche caduca solo en <= 24 h.** No hace falta actualizar para
> perderlo: `Internal::CheckNewVersionsJob` hace ping diario a
> `hub.2.chatwoot.com` y reescribe el plan con lo que responda el hub. Con el
> identifier actual la ventana es todos los dias a las **16:16 UTC**.
> 2. **Los 3 `UPDATE` de este caso no alcanzan** si el plan ya paso por
> `community`: `Internal::ReconcilePlanConfigService` apago los 9 feature
> flags premium en `accounts.feature_flags` de cada cuenta. Hay que correr
> `Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures`.
>
> Causa raiz completa, plan de actualizacion y fix durable:
> [docs/runbooks/chatwoot-update.md](../runbooks/chatwoot-update.md).
## 0. Resumen ejecutivo
@@ -41,7 +56,7 @@
|---|---|---|
| Host | Docker daemon local | Proxmox VE `192.168.0.200` |
| Ejecucion Docker | `docker exec` directo | `pct exec 102 --` + `docker exec` |
| Usuario SSH | n/a | `root@192.168.0.200` con `~/.openclaw/workspace/proxmox_key_win` |
| Usuario SSH | n/a | `root@192.168.0.200` con `keys/proxmox_ed25519` |
| Contenedor | filtro `name=pgvector` | nombre real: `postgres-c11xzy2tx2cdapm32f5b89vy` |
| DB user / db | `-U postgres -d chatwoot` | `-U <POSTGRES_USER> -d <POSTGRES_DB>` autodetectados (imagen `pgvector/pgvector:pg12` **no crea rol `postgres`**) |
@@ -124,20 +139,29 @@ El script implementa el equivalente exacto y valida los `UPDATE 1`.
2. Sube 3 scripts `.sh` y un `.sql` al host Proxmox (no al LXC, para evitar
un `pct push` extra y problemas de ruta).
3. Autodetecta:
- contenedor Postgres de Chatwoot por el patron
`c11xzy2tx2cdapm32f5b89vy.*(pgvector|postgres|db)`.
- contenedor Postgres de Chatwoot: filtra `docker ps` por el uuid del
servicio y luego por `(pgvector|postgres|db)`. Son **dos greps
encadenados** a proposito — Coolify nombra los contenedores
`<servicio>-<uuid>` (`postgres-c11xzy...`), asi que el patron unico
`<uuid>.*postgres` que tenia antes no casaba nunca y la autodeteccion
fallaba siempre (corregido el 2026-07-24).
- `POSTGRES_USER` / `POSTGRES_DB` / `POSTGRES_PASSWORD` desde
`docker inspect`.
4. Ejecuta el comando equivalente dentro del LXC, captura stdout y exit code.
5. Cuenta las lineas `^UPDATE\s+1\s*$`; **deben ser exactamente 3** o falla.
6. Corre un `SELECT` de verificacion.
7. Limpia los archivos temporales en el host Proxmox.
7. Con `-ReenableAccountFeatures`: reactiva los 9 feature flags premium en todas
las cuentas via `rails runner` y falla si queda alguno pendiente.
8. Limpia los archivos temporales en el host Proxmox.
En el `-DryRun` la `PGPASSWORD` sale enmascarada (antes se imprimia en claro).
### Uso
```powershell
# Desde la raiz del repo.
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun -ReenableAccountFeatures
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -Container "postgres-c11xzy2tx2cdapm32f5b89vy"
```
@@ -145,10 +169,18 @@ Sin `-Container`, el script lo busca por el UUID del recurso Coolify.
Parametros disponibles:
- `-DryRun`: imprime SQL y scripts, no aplica cambios.
- `-Container <nombre>`: fuerza el contenedor destino.
- `-ReenableAccountFeatures`: ademas de los 3 `UPDATE`, reactiva via
`rails runner` los 9 feature flags premium en **todas** las cuentas y verifica
que no quede ninguno pendiente. **Necesario siempre que el plan venga de
`community`** (ver el aviso al inicio de este documento).
- `-Container <nombre>`: fuerza el contenedor Postgres destino.
- `-AppContainer <nombre>`: contenedor de la app Rails, por defecto
`chatwoot-<ServiceUuid>` (solo lo usa `-ReenableAccountFeatures`).
- `-ServiceUuid <uuid>`: uuid del servicio en Coolify, por defecto
`c11xzy2tx2cdapm32f5b89vy`. De aqui se derivan los nombres de contenedor.
- `-LxcId <id>`: por defecto `102` (Coolify).
- `-ProxmoxHost <host>`: por defecto `192.168.0.200`.
- `-SshKey <ruta>`: por defecto `C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win`.
- `-SshKey <ruta>`: por defecto `keys\proxmox_ed25519`.
### Salida esperada (exitosa)
@@ -222,7 +254,15 @@ resultado, verificar con SELECT, limpiar) es identico.
## 6. Verificacion manual despues del parche
1. Entrar a `https://chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org`.
Automatica primero:
```powershell
.\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
```
Luego a mano:
1. Entrar a `https://chat.urieljareth.org`.
2. Iniciar sesion con un super admin.
3. Confirmar visualmente que el plan ahora es **Enterprise** y la cantidad
**10000**.
@@ -0,0 +1,117 @@
# Caso: error 500 al abrir la página de una aplicación en Coolify (env var sin cifrar)
**Fecha:** 2026-09-04 · **App:** `open-seo:main-0fgs5kwaab9esytaxkddsvts`
(uuid `kj0kccsb4d46tm0d6qe6docy`, id DB 57, repo `every-app/open-seo`, build pack
railpack) · **Resuelto el mismo día.**
## Síntoma
Abrir
`https://coolify.urieljareth.org/project/.../application/kj0kccsb4d46tm0d6qe6docy`
devuelve **500 (Server Error)**. El resto del dashboard funciona. El sitio
público de la app responde 200 (lo sirve un sidecar manual, ver "Estado
post-fix").
## Causa raíz
La fila 1298 de `environment_variables` (`DATAFORSEO_API_KEY` de la app 57)
tenía el **valor en texto plano** (56 chars, sin prefijo `eyJpdiI6`) con
`is_literal=false`. En Coolify v4.3.x el accessor `value` del modelo
`EnvironmentVariable` **descifra incondicionalmente** (`decrypt($value)`); un
valor no cifrado lanza `DecryptException: The payload is invalid`.
La página de configuración monta `ConfigurationChecker` (Livewire), que llama a
`Application->pendingDeploymentConfigurationDiff()` →
`ApplicationConfigurationSnapshot::environmentItems()` → lee `->value` de cada
env var → explota → la página entera responde 500. El stack trace está en
`storage/logs/laravel.log` del contenedor `coolify`.
El valor llegó por un **INSERT/UPDATE directo a la DB** (sin pasar por el modelo
Eloquent, que cifra en el `set`). Huella correlativa: 5 filas más con el morph
type mal escapado (`App\\Models\\Application`, doble backslash), también
inserciones directas por SQL (ver "Hallazgos secundarios").
## Diagnóstico (réplicable)
```powershell
# 1) Stack trace del 500 (dentro del contenedor coolify):
# docker exec coolify tail -n 200 /var/www/html/storage/logs/laravel.log
# -> DecryptException desde EnvironmentVariable::get_environment_variables
# 2) Clasificar filas SIN imprimir valores (todo payload cifrado de Laravel
# empieza con "eyJpdiI6"):
pct exec 102 -- docker exec coolify-db sh -c 'psql -U "$POSTGRES_USER" \
-d "$POSTGRES_DB" -c "SELECT id, key, is_literal, length(value) AS len, \
(value LIKE $$eyJpdiI6%$$) AS looks_enc FROM environment_variables \
WHERE resourceable_id=57;"'
```
## Fix aplicado
Re-cifrar el valor existente con el `APP_KEY` de la instancia (preserva el
secreto; no hace falta reingresarlo), vía un script PHP con Laravel booteado
dentro del contenedor `coolify`:
```php
// /tmp/fix-envvar.php (se pasa por stdin a: docker exec -i coolify sh -c 'cat > /tmp/fix-envvar.php')
require '/var/www/html/vendor/autoload.php';
$app = require '/var/www/html/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
$row = DB::table('environment_variables')->where('id', 1298)->first();
try { decrypt($row->value); echo "ya cifra OK\n"; }
catch (\Throwable $e) {
DB::table('environment_variables')->where('id', 1298)->update([
'value' => encrypt($row->value), // el secreto no se pierde
'updated_at' => now(),
]);
}
```
Wrapper ejecutable: [artifacts/fix-envvar-1298.ps1](../../artifacts/fix-envvar-1298.ps1)
(hace el backup, aplica y verifica en una pasada).
**Backup previo** (incluye el valor, root-only, host Proxmox):
`/root/backups/envvar-1298-20260904-211001.tsv`.
**Rollback:** restaurar la fila desde el backup
(`UPDATE environment_variables SET value='<col 3 del tsv>' WHERE id=1298;`) —
solo si se quisiera volver al estado roto original; no hay razón para hacerlo.
## Verificación
- Lectura a nivel de modelo OK (el accessor ya no lanza).
- `Application::find(57)->pendingDeploymentConfigurationDiff()` — la ruta exacta
que 500eaba — ejecuta limpio.
- Fila post-fix: `len=312`, `looks_enc=t`, `updated_at=2026-09-05 03:10:04`.
## Estado post-fix de la app (no parte de este caso)
- La app en Coolify sigue `exited:unhealthy` **sin contenedor** (última online
2026-08-27). Su página ya carga; un redeploy es decisión del usuario.
- El FQDN `https://kj0kccsb4d46tm0d6qe6docy.urieljareth.org` responde **200 en
vivo** (`cf-cache-status: DYNAMIC`) porque el contenedor manual
`open-seo-sidecar` (puerto 80, corriendo fuera de Coolify) lleva los labels
Traefik de ese host. Es decir: el sitio público no depende hoy del deployment
de Coolify.
## Hallazgos secundarios (sin acción, reportados al usuario)
- **5 filas huérfanas** (ids 1152-1156: `MYSQL_DATABASE`, `MYSQL_USER`,
`MOSTRAR_ENLACE`, `SEMBRAR_SIEMPRE`, `ENLACES_POR_VENTANA`) apuntan a la app
52 (`insta-portal`) con `resourceable_type='App\\Models\\Application'`
(doble backslash). La relación de Eloquent no las ve, así que **no rompen
páginas**, pero insta-portal corre sin esas variables. Normalizar el morph
type (y re-cifrar valores) las activaría — evaluar impacto en runtime antes.
## Prevención
- Nunca escribir en `environment_variables.value` por SQL directo: el modelo
cifra en el setter. Para insertar variables usar la UI o la API.
- Si se inserta por SQL de emergencia, el valor debe ser `encrypt($valor)` con
el `APP_KEY` de la instancia, y `resourceable_type` lleva **un solo**
backslash (`App\Models\Application`).
- Síntoma distintivo para el futuro: dashboard 500 solo en la página de una app
concreta + `DecryptException` en `laravel.log` = valor corrupto en
`environment_variables` de ese recurso.
@@ -0,0 +1,310 @@
# Caso: un servicio nuevo de Coolify está en verde pero el dominio devuelve `503 no available server`
> Diagnosticado y resuelto el **2026-08-23** contra el host real.
> Target: **LXC 102** (`coolify`) en el host Proxmox `thinkcentre` (`192.168.0.200`).
> Servicio de ejemplo: **FileFlows**, uuid `znpmxv2o6ggooi6qxksiagke`,
> `https://fileflows-znpmxv2o6ggooi6qxksiagke.urieljareth.org`.
>
> **Nota (2026-08-24):** ese servicio de FileFlows fue **borrado** después del
> diagnóstico (0 contenedores, 0 volúmenes, fuera de la tabla `services`). No lo
> busques. Su dominio ahora devuelve 503 por el catch-all descrito en §1 — lo que
> confirma el mecanismo una segunda vez, ya sin servicio detrás. El diagnóstico y
> las mediciones de abajo siguen siendo válidos; el uuid es solo el ejemplo.
---
## 0. Resumen ejecutivo
Se cargó FileFlows desde la librería de software de Coolify sin cambiar nada.
Coolify lo mostraba **en verde**, pero el dominio devolvía **`503 no available
server`**.
**No había ningún error de configuración.** Ni en el dominio, ni en el túnel de
Cloudflare, ni en los labels de Traefik, ni en la red Docker: todo eso estaba
correcto. Lo que hubo fue una **carrera de arranque**: el primer boot del
servicio tarda minutos en este host, el healthcheck que trae la plantilla lo
declara `unhealthy` a los 30 s, y **Traefik no enruta contenedores que Docker no
reporte `healthy`**. Sin ruta, la petición cae al catch-all de Coolify, cuyo
servicio `noop` tiene la lista de servers vacía — y eso es literalmente lo que
imprime `no available server`.
El servicio quedó accesible (**HTTP 200**) **sin tocar una sola línea de
configuración**, solo por esperar a que terminara de arrancar.
---
## 1. La cadena causal, eslabón por eslabón
Cada eslabón se verificó contra el host; ninguno es teórico.
| # | Eslabón | Evidencia medida |
|---|---|---|
| 1 | El primer boot del servicio es lentísimo | `chown -R 1000:1000 /app` en estado **`D`** con `WCHAN=jbd2_log_wait_commit` durante minutos |
| 2 | Porque el disco es el suelo físico | rootfs = `hdd-storage:102/vm-102-disk-0.raw` → **ext4 sobre `loop0` sobre un `.raw` en HDD**. Latencia media de escritura: **38,9 ms** en `loop0`, **26,6 ms** en `sdb`. `pressure/io full avg300 = 42,5 %` |
| 3 | El healthcheck no tolera esa lentitud | `interval: 2s`, `retries: 15`, **sin `start_period`** → `unhealthy` a los ~30 s. `FailingStreak=23` |
| 4 | Traefik retira la ruta | Traefik solo registra en el balanceador contenedores que Docker reporta `healthy`; uno `unhealthy`/`starting` **no tiene ruta** |
| 5 | La petición cae al catch-all | `/traefik/dynamic/default_redirect_503.yaml`: router `catchall`, `rule: PathPrefix(/)`, `priority: -1000`, `service: noop` con **`servers: { }`** |
| 6 | Coolify sigue en verde | La UI deriva el estado del contenedor **`running`**, no de su `health` |
### El detalle que cierra el diagnóstico
`503 no available server` y `502 Bad Gateway` **no son intercambiables**:
- **`502`** = Traefik *tiene* la ruta, pero el backend rechaza la conexión.
- **`503 no available server`** = Traefik **no tiene** ningún server para ese
host. Es la respuesta del servicio `noop` con lista vacía.
Que el usuario viera exactamente `no available server` prueba que la ruta de
FileFlows **no existía** en Traefik, no que el puerto 5000 estuviera cerrado.
Es el detalle que distingue "hay que esperar" de "hay que arreglar algo".
### Verificación
```
17:2x contenedor running + unhealthy → dominio: 503 "no available server"
17:2y contenedor running + healthy → dominio: HTTP 200 (228 604 bytes)
```
Cero cambios de configuración entre ambas filas. La única variable que se movió
fue el `health` del contenedor.
### Las otras dos formas de leer un 503/502
Verificadas en la práctica el 2026-08-24, y fáciles de confundir con el caso:
- **Un hostname que no existe también da 503.** Probando dominios *adivinados*
(`n8n.urieljareth.org` en vez del real `n8.urieljareth.org`,
`nextcloud.` en vez de `nextcloudsuite.`) sale el mismo 503 del catch-all.
Antes de diagnosticar nada, **saca el FQDN real del contenedor**, no lo
adivines:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker inspect <cont> --format '{{range .Config.Env}}{{println .}}{{end}}'"
```
- **Un `502` es un problema de puerto, no de salud.** `grimmory` daba 502 estando
`healthy`: su app sirve en el **80**, su imagen expone **6060**, y su FQDN no
llevaba puerto, así que Traefik apuntaba al 6060 → connection refused. Detalle
y arreglo en [§1.6 del índice](../TOOL-INDEX.md).
*(Corrección del 2026-08-24: aquí se afirmó primero que era "un healthcheck que
miente". Era falso — su healthcheck probaba el puerto 80 y pasaba con razón.)*
- **Pero un healthcheck sí puede mentir, y en este host pasa.** El de `grimmory`
probaba `http://127.0.0.1/health`, y en un SPA **esa ruta la responde el
fallback con `index.html`**: devuelve 200 aunque el backend y la base de datos
estén muertos. Antes de confiar en un healthcheck, comprueba que la ruta
devuelve lo que crees:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> sh -c 'wget -qO- http://127.0.0.1/health | head -c 80'"
```
Si sale `<!doctype html>`, el check no vale nada. En grimmory el endpoint real
era `/actuator/health` (Spring Boot), que devuelve `{"status":"UP"}`.
---
## 2. Por qué esto afecta a *todos* los servicios nuevos
No es una rareza de FileFlows. Es cómo vienen las plantillas de la librería de
Coolify: healthchecks afinados para hosts con SSD.
Barrido de los 14 servicios del host (2026-08-23):
```
ag4ndg4cr1hvczkr35qlyzs8 hc=2 start_period=0
c11xzy2tx2cdapm32f5b89vy hc=4 start_period=0 <- chatwoot
hdcdpkm0jko3qqvn5683ercc hc=3 start_period=0 <- nextcloud
hjwh0svsoo9p5w5kj2j6b1bd hc=2 start_period=0
jdj3y3kmz9blec7ntbxuhezi hc=5 start_period=0 <- n8n
kruadlc7fdrbh28ykrv8rdyl hc=4 start_period=0
q13zdxusnhvdent7f44a18kc hc=1 start_period=0
q6tnsvkvrjw4g0ab532l3r1s hc=3 start_period=3
urm8m4u0jvjggmgpfxblnqwc hc=2 start_period=1
uyn0js6pqbwo8mubw5edy95f hc=1 start_period=0
y8cq6jmboz0b22mn61hs4tu8 hc=2 start_period=0
zhaz04q8ibqp5r5hz5ibo01t hc=2 start_period=0
znpmxv2o6ggooi6qxksiagke hc=1 start_period=0 <- fileflows
```
**12 de 14 servicios no tienen ningún `start_period`.** Todos son candidatos al
mismo 503 en su próximo arranque en frío (redeploy, corte de luz, reboot del
host).
### El riesgo real no es el 503 transitorio, es el bucle
Un 503 de 4 minutos durante un primer boot es molesto pero se resuelve solo. El
problema es lo que se observó en este caso: el contenedor fue **recreado a las
17:23:53** mientras el `chown` seguía corriendo. Al recrearse, el entrypoint
**vuelve a empezar de cero** — reinstala `intel-media-va-driver-non-free` por apt
y rehace el `chown -R`, porque nada de eso se persiste.
Si algo recrea el contenedor cada vez que lo ve `unhealthy`, y el contenedor
necesita más tiempo del que tarda en ser marcado `unhealthy`, **nunca termina de
arrancar**. Eso convierte un 503 transitorio en un 503 permanente. `start_period`
es precisamente lo que rompe ese bucle.
Anotación honesta: `start_period` **no** hace que el sitio responda antes.
Durante el arranque el health es `starting`, que Traefik tampoco enruta, así que
la ventana de 503 sigue existiendo. Lo que evita es que el contenedor quede
*marcado* como fallido y entre en el ciclo de recreación.
---
## 3. Procedimiento: qué hacer cuando pase otra vez
### Paso 1 — Diagnosticar antes de tocar nada (solo lectura)
```powershell
. .\.env.local.ps1
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid-del-servicio>
```
El script muestra `State` y `Health` **uno al lado del otro** (que es la
discrepancia que la UI de Coolify esconde), avisa si el `start_period` es
insuficiente, detecta si el entrypoint sigue haciendo trabajo de setup
(`chown`/`apt`/`dpkg`), y prueba el dominio distinguiendo 503 de 502.
### Paso 2 — Si sigue arrancando, esperar. No redeployar.
```powershell
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
```
**Redeployar es contraproducente**: reinicia el entrypoint desde cero y reinicia
la cuenta del arranque lento. Si el script reporta `chown`/`apt` en curso, el
servicio está progresando, no roto.
### Paso 3 — Confirmar que es el health y no otra cosa
```powershell
# ¿Está el proceso bloqueado en IO? Estado D + jbd2_log_wait_commit = disco, no bug.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- ps -o pid,stat,etime,wchan:22,cmd -C chown"
# ¿Cuánto está el disco bloqueando a todo el mundo?
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- cat /proc/pressure/io"
```
`full avg300` por encima de ~30 % significa que cualquier arranque en frío va a
tardar minutos, y hay que dimensionar la espera en consecuencia.
---
## 4. Arreglos durables
**Estado al 2026-08-24:** el 4.1 está **aplicado a los 13 servicios**; el 4.2 y
el 4.3 siguen pendientes de decisión.
### 4.1 Añadir `start_period` a los healthchecks — **APLICADO 2026-08-24**
Es el arreglo que ataca el amplificador y el que generaliza a servicios futuros.
Ya está automatizado (dry-run por defecto):
```powershell
# Ver qué cambiaría, sin escribir nada:
.\coolify_skill\scripts\Set-CoolifyHealthcheckGrace.ps1 -Uuid <uuid> -ShowResult
# Escribirlo (guarda copia de rollback en backups\ y verifica leyendo de vuelta):
.\coolify_skill\scripts\Set-CoolifyHealthcheckGrace.ps1 -Uuid <uuid> -Apply
```
El script edita `services.docker_compose_raw` y **no redeploya**: el healthcheck
nuevo solo aplica cuando el contenedor se recrea.
**Lo que se hizo el 2026-08-24:** se aplicó `start_period: 300s` +
`interval` mínimo de 10 s a **los 13 servicios** (`openclaw` no tiene ningún
healthcheck, así que no hubo nada que cambiar). Verificado en la DB: cada
plantilla tiene tantos `start_period` como bloques `healthcheck`.
**Deliberadamente no se redeployó nada.** Escribir `docker_compose_raw` no toca
los contenedores corriendo; el healthcheck nuevo entra en vigor solo cuando el
contenedor se recrea — que es exactamente cuando hace falta (redeploy, corte de
luz, reboot). Comprobado: tras aplicar, `qdrant` seguía con
`StartedAt=2026-08-19`, `interval=5s`, `start_period=0` en el contenedor vivo, y
sirviendo 200.
Consecuencia práctica: `Test-CoolifyServiceReady.ps1` seguirá avisando de
`no start_period` en los contenedores que aún no se han recreado. **Eso es
correcto**: reporta el contenedor vivo, no la plantilla. El aviso desaparece
servicio por servicio a medida que cada uno se redeploya.
Copias de rollback en `backups/` (gitignored), una por servicio.
El resultado equivale a:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/system/version"]
interval: 10s # 2s genera un exec de curl cada 2 s sobre un disco ya saturado
timeout: 10s
retries: 15
start_period: 300s # <- lo que falta
```
- **Pro:** rompe el bucle de recreación; es un cambio pequeño y reversible.
- **Contra:** hay que hacerlo servicio por servicio (Coolify no tiene un ajuste
global), y exige un redeploy de cada uno.
### 4.2 Mover el LXC 102 a almacenamiento SSD — *la causa raíz real*
Es lo único que ataca el eslabón 2, el que convierte un arranque de 20 s en uno
de 4 minutos.
- **Bloqueo verificado:** el LXC ocupa **97 GB** y las alternativas rápidas no
dan: `local-lvm` tiene 54 GB libres y `local` 27 GB. **No cabe.**
- Requiere hardware nuevo (un SSD) o reducir antes la huella del LXC.
- **Es una decisión tuya**, no algo que deba aplicar por mi cuenta.
### 4.3 Bajar `vm.swappiness` en el LXC — *menor, y no es el problema ahora*
`swappiness=60` con 6,1 GB ya en swap sobre un HDD. Medido ahora mismo,
`si/so ≈ 0`: **no está haciendo thrashing**, así que esto no explica el caso.
Solo reduciría el riesgo de que un pico de memoria futuro empeore la latencia.
Prioridad baja.
---
## 5. Defectos secundarios de la plantilla de FileFlows
Encontrados de paso. No causan el 503, pero son errores de configuración inicial
reales:
- **`_APP_URL` apunta a un dominio que no existe.** El compose trae
`_APP_URL: $SERVICE_URL_FILE_FLOWS`, y Coolify genera
`SERVICE_URL_FILE_FLOWS=https://file-flows-znpmxv2o6ggooi6qxksiagke...` — con
**guion**, `file-flows`. El dominio real es `fileflows`, sin guion. Ese
hostname con guion no tiene ni ruta en Traefik ni DNS.
- **`SERVICE_URL_FILEFLOWS_5000` lleva el puerto pegado:**
`https://fileflows-...urieljareth.org:5000`. Para un servicio detrás del proxy
eso es incorrecto; el 5000 es interno.
Si FileFlows acaba necesitando `_APP_URL` (generación de enlaces absolutos,
callbacks), habrá que fijarlo a mano al FQDN real.
---
## 6. Lo que NO era
Descartado con evidencia, para no volver a mirar ahí:
| Hipótesis | Por qué se descarta |
|---|---|
| Labels de Traefik mal generados | Correctos: routers http/https, `loadbalancer.server.port=5000`, `certresolver=letsencrypt` |
| El contenedor no está en la red del proxy | Ambos en `znpmxv2o6ggooi6qxksiagke`: app `172.27.0.2`, `coolify-proxy` `172.27.0.3` |
| `traefik.docker.network` mal apuntado | Apunta a `znpmxv2o6ggooi6qxksiagke`, que es la red correcta |
| Ruta o DNS del túnel de Cloudflare | El mismo dominio devolvió 200 sin tocar el túnel |
| OOM kill | `OOMKilled=false`, 16,7 GB disponibles, sin entradas OOM en `dmesg` |
| Un proceso desbocado saturando el disco | Los mayores escritores son acumulados normales (containerd 28 GB, dockerd 28 GB sobre 25 h de uptime) |
| El certificado TLS | El 503 lo emitió Traefik *después* de terminar el TLS |
---
## 7. Antes de usar esto
Verificado el 2026-08-23 contra el host real. Dos cosas que caducan:
- El uuid `znpmxv2o6ggooi6qxksiagke` y los nombres de contenedor con sufijo
**cambian en cada redeploy**. Resuélvelos, no los copies.
- El barrido de `start_period` es una foto de ese día. Vuelve a correrlo antes de
apoyarte en él.
+89
View File
@@ -0,0 +1,89 @@
# Caso: integración Evolution API ↔ Chatwoot — "Something went wrong in importing messages"
> Resuelto el **2026-09-02** contra el host real.
> Servicios: `evolution-api` v2.3.7 (`q6tnsvkvrjw4g0ab532l3r1s`,
> https://evoapi.urieljareth.org) y Chatwoot v4.16.2
> (`c11xzy2tx2cdapm32f5b89vy`, https://chat.urieljareth.org).
> Resultado: flujo en vivo bidireccional OK en inbox 6 (Asesoría Personal) e
> inbox 10 (Personal), inbox 12 (JM) creado, errores de importación
> desactivados (limitación de upstream, ver §2).
---
## 0. Síntomas reportados
1. En la conversación de estado aparecía
`💬 Something went wrong in importing messages.` (inbox 6, conv 29).
2. "La instancia no funciona": el mensaje de prueba del usuario (desde su
número personal al de Asesoría) no aparecía.
## 1. Diagnóstico (verificado con logs + código fuente 2.3.7)
### 1.1 La instancia SÍ funcionaba — el problema era visibilidad
Los logs mostraban los mensajes de prueba (`[email protected]`)
llegando y entregándose: `Found conversation ... ID: 22 - Name: Uriel Jareth`.
La conversación 22 existía pero estaba **`pending`**: Chatwoot no muestra las
pendientes en la bandeja "Abiertas" → parecía que no llegaba nada.
### 1.2 La importación de historial es imposible en esta topología (upstream)
Cadena del error:
- El importador (`chatwoot-import-helper.ts`) escribe **directo a la DB de
Chatwoot** vía `CHATWOOT_IMPORT_DATABASE_CONNECTION_URI` — no hay fallback
por API en 2.3.7.
- El stack de Evolution trae esa URI apuntando a **su propio postgres**
(`postgres:5432/chatwoot`) — una base que ahí no existe (Chatwoot usa su
postgres en otro stack, DB `chatwoot`, `ssl=off`).
- El cliente (`libs/postgres.client.ts`) **fuerza `ssl: {rejectUnauthorized:
false}` siempre** → contra cualquier postgres de este host (todos
`ssl=off`) el resultado es
`Error on getExistingSourceIds: The server does not support SSL connections`
→ `Something went wrong in importing messages`.
Habilitar SSL en el postgres de Chatwoot habría arriesgado el stack
parcheado a mano; se descartó. La decisión: **desactivar la importación**
(`importMessages=false`, `importContacts=false`) y operar solo con el flujo
en vivo. Conclusión práctica: **el historial previo del teléfono no se
importa** — exactamente el techo que impone WhatsApp de todas formas (ver
discusión en [evolution-go-stack.md](evolution-go-stack.md) §3).
### 1.3 JM estaba roto de fábrica
- Su `chatwoot.url` tenía **slash final** (`https://chat.urieljareth.org/`) —
la doc exige sin slash.
- No existía su inbox en Chatwoot → warnings `inbox not found` en bucle.
### 1.4 INSTA queda pendiente (decisión del usuario)
Desconectada (`close`), `accountId=2` (solo existe la cuenta 1), token
distinto y sin inbox. Mientras esté `enabled=true` seguirá dando avisos
`inbox not found`. Reactivarla exige re-escanear QR + corregir accountId.
## 2. Fixes aplicados (2026-09-02)
| Fix | Cómo | Resultado |
|---|---|---|
| Import fuera | `POST /chatwoot/set/{Asesoria Personal,Personal,JM}` con `importMessages=false`, `importContacts=false` | 201; 0 ERROR en logs después |
| Conversaciones visibles | `conversationPending=false` en las tres + `toggle_status` de la conv 22 → open | conv 22 abierta en inbox 6 |
| JM reparado | misma llamada con URL sin slash + `autoCreate=true` | **inbox 12 "JM" creado** |
| Webhooks | verificados intactos en inbox 6 y 10 (`…/chatwoot/webhook/{instancia}`) | sin cambios |
## 3. Mapa actual de la integración
| Instancia | Inbox | Estado |
|---|---|---|
| Asesoría Personal (5214438634306) | 6 | open, flujo bidireccional verificado en logs |
| Personal (5214451052792) | 10 | open, verificado por el usuario |
| JM (5214451672052) | 12 | open, inbox recién creado |
| INSTA | — | close + accountId=2: reactivar a decisión del usuario |
Notas operativas:
- El parámetro `daysLimitImportMessages` queda inertre (importMessages=false).
- Re-conectar una instancia o re-guardar la config de Chatwoot ya no dispara
importaciones fallidas.
- Si algún día se quiere importación de historial de verdad: habilitar SSL en
un postgres y cruzar redes de stacks, o esperar que upstream añada fallback
por API (el importador 100% SQL-Direct está en 2.3.7).
+180
View File
@@ -0,0 +1,180 @@
# Caso: evolution-go (API WhatsApp en Go) desplegado junto a Chatwoot
> Resuelto el **2026-09-02** contra el host real.
> Target: **LXC 102** (`coolify`), proyecto `AI AGENCY` / `production`.
> Resultado: `https://evo.urieljareth.org/server/ok` → **HTTP 200**
> (`{"status":"ok"}`), Manager UI operativo, contenedores `healthy`.
> **Licencia ACTIVA desde el 2026-09-02** (vía OAuth Google del portal; ver §3.0).
---
## 0. Resumen ejecutivo
El usuario pidió clonar
[evolution-foundation/evolution-go](https://github.com/evolution-foundation/evolution-go)
(rewritten en Go de Evolution API, motor WhatsApp sobre whatsmeow) e
"implementar la funcionalidad" en el servicio Chatwoot
(`/project/cho4d488omzjm4noyz98mwq7/environment/xhcy6urwmtk0onnxoeec24hq/service/c11xzy2tx2cdapm32f5b89vy`).
**Decisión:** el stack se desplegó como **servicio hermano** (`evolution-go`,
uuid `j0jkacsfcgypm2jmpillls01`) en el **mismo proyecto y entorno** que Chatwoot,
no dentro del compose de Chatwoot. Motivos:
- Editar el compose del stack Chatwoot fuerza un redeploy completo de Chatwoot
(riesgo sobre un stack parcheado a mano — ver
[chatwoot-enterprise-patch.md](chatwoot-enterprise-patch.md)).
- Evolution-go necesita su propio PostgreSQL; meterle una DB ajena al stack de
Chatwoot complica el rollback.
- La integración WhatsApp→Chatwoot se hace por webhook/API hacia el FQDN público
de Chatwoot — no requiere red compartida.
El clon local vive en `projects/evolution-go` (tag 0.7.2). El compose de
despliegue vive versionado en [`stacks/evolution-go/docker-compose.coolify.yml`](../../stacks/evolution-go/docker-compose.coolify.yml).
| Pieza | Valor |
|---|---|
| Servicio Coolify | `evolution-go` (`j0jkacsfcgypm2jmpillls01`) |
| Proyecto / entorno | `AI AGENCY` / `production` (mismo que Chatwoot) |
| Imagen app | `evoapicloud/evolution-go:0.7.2` (pinada, Docker Hub) |
| Imagen DB | `postgres:16-alpine` (hermana, `evolution-postgres`) |
| FQDN | `https://evo.urieljareth.org` (wildcard del túnel, sin cambios CF) |
| Healthcheck | `wget http://127.0.0.1:8080/server/ok` (200 sin licencia) |
| Secretos | `GLOBAL_API_KEY` (40 car.) y `POSTGRES_PASSWORD` (24 car.) como envs del servicio, generados en memoria |
---
## 1. Bugs y trampas encontrados (lo que costó tiempo)
### 1.1 `POSTGRES_AUTH_DB` vacía = panic (bug upstream 0.7.2)
El primer arranque crash-loopeaba (exit 2). Stack trace:
`NewPollService → autoMigrate` sobre un `*sql.DB` **nil**.
Cadena exacta en el código:
- `cmd/evolution-go/main.go:300` — `initPostgresAuthDB()` devuelve `(nil, nil)`
cuando `POSTGRES_AUTH_DB == ""`: **sin error**.
- `main.go:408` pasa ese nil a `setupRouter`.
- `pkg/poll/service/poll_service.go:39` — `autoMigrate` dereferencia el nil → panic.
Es decir: la variable **parece opcional** (README no la marca obligatoria) pero
sin ella el binario muere en loop. El fix fue setearla como URI completa:
`postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@evolution-postgres:5432/evogo_auth?sslmode=disable`
(interpolada por Coolify al deploy, el secreto no vive en el compose).
### 1.2 Los nombres de env del README están desactualizados
`docker/examples/docker-compose.yml` y el README usan `WADEBUG`/`LOGTYPE`, pero
el código 0.7.2 (`pkg/config/env/env.go`) lee **`DEBUG_ENABLED`** y
**`LOG_TYPE`**. Además `DATABASE_SAVE_MESSAGES` es obligatoria no-vacía
(`panicIfEmpty`) aunque parezca opcional. La fuente de verdad es `env.go`.
### 1.3 `PATCH /services/{uuid}` rechaza campos de creación
`New-CoolifyService.ps1` en su flujo de actualización (`-ServiceUuid`) enviaba
`project_uuid`/`environment_name`/`server_uuid` y la API responde
**422 "This field is not allowed"** para los tres. Solo acepta `name`,
`docker_compose_raw` y `urls`. Corregido en el script el 2026-09-02 (el caso
firecrawl solo ejercitó la creación, no la actualización).
### 1.4 La licencia bloquea la API — pero no al Manager
`GateMiddleware` (`pkg/core/c0.go:638`) devuelve **503 `LICENSE_REQUIRED`** en
todo hasta activar licencia, con excepciones: `/server/ok`, `/health`,
`/manager*`, `/assets*`, `/license/*`, `/swagger*`, `/ws`. Esto es crítico para
el healthcheck: como Traefik solo enruta contenedores `healthy` (ver
[el caso del 503](coolify-servicio-nuevo-503-no-available-server.md)), un
healthcheck contra cualquier endpoint bloqueado habría dejado al Manager
**inalcanzable** — deadlock imposible de activar. `/server/ok` responde 200
siempre.
### 1.5 No se puede montar `init-db.sql` por ruta
El compose de upstream monta `./init-db.sql` en el postgres. En Coolify el
compose se guarda como `docker_compose_raw` en la DB — no hay árbol de archivos.
No hace falta: `ensureDBExists()` (`pkg/config/config.go:79`) crea las DBs del
DSN al arrancar (evogo_auth, evogo_users).
---
## 2. Cómo se reproduce
```powershell
. .\.env.local.ps1
# 1. Crear (sin arrancar)
.\deploy_skill\scripts\New-CoolifyService.ps1 `
-AppPath .\stacks\evolution-go `
-AppName evolution-go `
-Fqdn https://evo.urieljareth.org `
-PrimaryService evolution-go `
-ProjectName "AI AGENCY" -EnvironmentName production -NoDeploy -Force
# 2. Secretos (POST /envs da 409 con vars ya sembradas: usar bulk PATCH;
# generados en memoria, nunca en disco)
# PATCH /services/j0jkacsfcgypm2jmpillls01/envs/bulk
# data: [{POSTGRES_PASSWORD}, {GLOBAL_API_KEY}] con is_literal
# 3. Arrancar y esperar (primer boot: minutos)
Invoke-CoolifyApi.ps1 -Method POST -Path "/services/j0jkacsfcgypm2jmpillls01/start"
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid j0jkacsfcgypm2jmpillls01 -WaitSeconds 600
# 4. Verificación funcional
curl.exe -sS https://evo.urieljareth.org/server/ok # {"status":"ok"}
curl.exe -sSI https://evo.urieljareth.org/manager/login # 200
```
---
## 3. Qué queda pendiente del lado del usuario (manual)
0. **Si el magic link del portal dice "ya usado o expiró"**: `GET /license/register`
**cachea en memoria** la primera sesión de registro (`rc._v8` en
`pkg/core/c0.go:710`) y devuelve siempre la misma `register_url` aunque el
portal ya la haya invalidado (link consumido por preview del cliente de
correo, doble click, o expirado). Fix verificado: `docker restart` del
contenedor app → siguiente `/license/register` pide sesión nueva al portal.
Después: abrir el magic link **una sola vez y directamente** (los previews
de Outlook/Gmail consumen links single-use sin que los abras). Mejor aún:
la página del portal ofrece **OAuth con Google/GitHub**, que evita el magic
link por completo (verificado 2026-09-02: la sesión sobrevive aunque el
magic link muera; tras el OAuth queda un `code` que se canjea con
`GET /license/activate?code=...`).
**Resultado final:** el magic link falló 3/3 (el cliente de correo del
usuario consumía el link single-use antes que el navegador — el portal
respondía `authorization code expired or already used` al canjear). La
activación se completó así: `docker restart` (limpia la sesión cacheada) →
iniciar el registro **desde el Manager** (para que mande su `redirect_uri`
y la vuelta sea automática) → en el portal, botón **Entrar com Google** →
redirección de vuelta al Manager → `/license/status` = `active`.
1. **Activar la licencia** (requiere cuenta en Evolution Foundation):
abrir `https://evo.urieljareth.org/manager/login`, entrar con la API URL
(`https://evo.urieljareth.org`) y la `GLOBAL_API_KEY` — visible en Coolify:
proyecto AI AGENCY → servicio evolution-go → pestaña Environment. Hasta
entonces toda la API responde 503 `LICENSE_REQUIRED` (el Manager sí funciona).
Alternativa por API: `GET /license/register` devuelve la URL de registro.
2. **Conectar WhatsApp**: desde el Manager crear una instancia → escanear el QR
(`POST /instance/create`, `GET /instance/qr` con header `apikey`). Las
sesiones persisten en el volumen `evolution-data` (`/app/dbdata`).
3. **Enlazar con Chatwoot**: evolution-go **no trae** integración Chatwoot
nativa (cero menciones en el código, a diferencia de evolution-api Node). El
puente sería por webhook (`WEBHOOK_URL`) hacia un inbox tipo API de Chatwoot,
o usar el servicio evolution-api Node viejo que sí la tiene.
## 4. Notas de estado
- El servicio viejo `evolution-api` (`q6tnsvkvrjw4g0ab532l3r1s`, imagen Node
`evoapicloud/evolution-api:v2.3.7`, mismo entorno) **está en producción en
https://evoapi.urieljareth.org** (api/postgres/redis, 13+ días healthy; el
contenedor app se llama `api-q6tn...`, no `evolution-api-...` — cuidado con
los greps). Tiene 4 instancias WhatsApp (Personal, JM, INSTA, Asesoria
Personal) sin integración Chatwoot configurada. NOTA 2026-09-02: este repo
documentó erróneamente "app detenida" por un filtro truncado de
`Get-CoolifyDockerStatus`; corregido tras verificación directa.
- El compose normalizado por Coolify borra los comentarios; la versión
documentada es la del repo (`stacks/evolution-go/`).
- Imagen pinada a `0.7.2` (tag del repo clonado). Para subir de versión:
cambiar el tag, repasar `pkg/config/env/env.go` del nuevo tag (los nombres de
variables cambian entre versiones) y PATCH + start de nuevo.
+196
View File
@@ -0,0 +1,196 @@
# Caso: firecrawl llevaba meses `exited` y sin URL — stack mínimo con imágenes precompiladas
> Resuelto el **2026-08-24** contra el host real.
> Target: **LXC 102** (`coolify`), proyecto `AI AGENCY` / `production`.
> Resultado: `https://firecrawl.urieljareth.org` → **HTTP 200**, scrape real
> verificado (`"success": true`).
---
## 0. Resumen ejecutivo
La app `firecrawl` de Coolify (`build_pack=dockercompose`, uuid
`du3iknyvy22vap767t9tnf9s`) estaba `exited:unhealthy` y sin dominio.
**Causa:** seguía `git_branch: main`, y firecrawl upstream se rediseñó. El compose
de `main` hoy trae **7 servicios**, incluidos **FoundationDB, RabbitMQ y
nuq-postgres**, con **3 compilados desde fuente** (`apps/api`,
`apps/playwright-service-ts`, `apps/nuq-postgres`) y `mem_limit: 8G` en `api` más
`4G` en playwright. Este LXC tiene **4 cores** y el disco escribe a ~26 ms.
Compilar Chromium ahí es el peor caso posible.
**Solución:** se dejó de seguir upstream. Nuevo **service** de Coolify con un
compose propio de **5 servicios y cero compilaciones**, todo con imágenes ya
publicadas. Vive en
[`stacks/firecrawl/docker-compose.coolify.yml`](../../stacks/firecrawl/docker-compose.coolify.yml).
**La URL no se había perdido ese día:** `docker_compose_domains` estaba vacío, el
compose generado no tenía ninguna regla `Host(...)` y Traefik **nunca** había
emitido certificado para un dominio de firecrawl. Tampoco había ningún despliegue
desde antes del 2026-07-31.
---
## 1. El stack que sí aguanta este host
| Servicio | Imagen | Notas |
|---|---|---|
| `api` | `ghcr.io/firecrawl/firecrawl:2.10.19` | pinado; sirve en **3002** |
| `playwright-service` | `ghcr.io/firecrawl/playwright-service:latest` | no publica tags de versión |
| `nuq-postgres` | `ghcr.io/firecrawl/nuq-postgres:latest` | sustituye al build de `apps/nuq-postgres` |
| `redis` | `redis:alpine` | sin persistencia (`--save "" --appendonly no`) |
| `rabbitmq` | `rabbitmq:3-management` | |
Fuera quedaron **`foundationdb` y `foundationdb-init`**: solo se usan si
`NUQ_BACKEND` está definido, y aquí se deja vacío a propósito.
Solo hay **versiones 2.10.x** publicadas (2.10.1 … 2.10.19). No existe una línea
antigua más liviana a la que bajarse.
### RabbitMQ da errores y no pasa nada
En el log de `api` aparece, de forma normal:
```
NuQ sender connection error ... "Cannot get a message from queue
'nuq.queue_scrape.prefetch' in vhost '/': noproc"
NuQ sender get failed, falling back to postgres
```
Es **degradación controlada**: la cola cae a postgres y firecrawl funciona. No es
el problema que hay que perseguir si algo va mal.
---
## 2. Las tres trampas que costaron tiempo
### 2.1 La imagen no trae `wget` — y el healthcheck decide si hay ruta
Verificado dentro del contenedor:
| Binario | ¿Está? |
|---|---|
| `curl` | sí (`/usr/bin/curl`) |
| `wget` | **NO** |
| `nc` | **NO** |
Y los endpoints:
| Ruta | Código |
|---|---|
| `/` | **200** |
| `/is-production` | 200 |
| `/test` | 404 |
| `/health` | 404 |
| `/v1/health` | 404 |
Un healthcheck con `wget` o contra `/health` **falla siempre**. Y como Traefik
solo enruta contenedores `healthy`, el dominio devuelve `503 no available server`
aunque la app esté perfectamente viva y escuchando en 3002 (ver
[el caso del 503](coolify-servicio-nuevo-503-no-available-server.md)).
El que funciona:
```yaml
healthcheck:
test: ['CMD', 'curl', '-fsS', '-o', '/dev/null', 'http://127.0.0.1:3002/']
```
**Comprueba siempre qué binarios y qué rutas existen antes de escribir un
healthcheck:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> sh -c 'command -v curl wget nc'"
```
### 2.2 El worker rechaza todo por carga
```
Can't accept connection due to RAM/CPU load
```
Los umbrales por defecto (`MAX_RAM`/`MAX_CPU` = 0.8) se superan constantemente en
un host compartido. Con `MAX_RAM: 0.95` y `MAX_CPU: 0.95` acepta trabajo.
### 2.3 `api` se queda en `Created` en el primer deploy
En el primer `compose up`, `api` quedó **`Created`** y nunca arrancó: sus
`depends_on: service_healthy` (rabbitmq y nuq-postgres) tardaron más que el
proceso de deploy. Un `restart` del service con las dependencias ya sanas lo
resolvió. Si ves `Created` sin logs ni error, no está roto: reinicia el service.
---
## 3. Cómo se reproduce
```powershell
. .\.env.local.ps1
.\deploy_skill\scripts\New-CoolifyService.ps1 `
-AppPath .\stacks\firecrawl `
-AppName firecrawl-min `
-Fqdn https://firecrawl.urieljareth.org `
-PrimaryService api `
-ProjectName "AI AGENCY" -EnvironmentName production -NoDeploy
# Secretos: Coolify siembra las variables desde los ${...} del compose con su
# valor por defecto. Hay que sobrescribir las que deben ser secretas por PATCH
# (POST devuelve 409 si ya existe): POSTGRES_PASSWORD y BULL_AUTH_KEY.
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
```
Verificación funcional (responder 200 en `/` no prueba que funcione):
```powershell
Invoke-RestMethod -Uri "https://firecrawl.urieljareth.org/v1/scrape" -Method POST `
-ContentType 'application/json' -Body '{"url":"https://example.com","formats":["markdown"]}'
# success = True
```
---
## 4. Tres bugs del toolkit que este caso destapó
Los tres estaban impidiendo que `New-CoolifyService.ps1` funcionara. **Corregidos
y verificados el 2026-08-24.**
1. **`type` junto a `docker_compose_raw`.** El script enviaba
`type = "one-click-service"` con un comentario que afirmaba que Coolify acepta
cualquier string. Es falso: la API responde
`422 "You cannot provide both service type and docker_compose_raw."`.
`type` es solo para servicios de la librería. **Se eliminó.**
2. **`docker_compose_raw` sin base64.** Se enviaba en crudo y la API responde
`422 "The docker_compose_raw should be base64 encoded."`.
3. **`Invoke-CoolifyApi.ps1` mandaba el body como *string*.** PowerShell 5.1
codifica un body string con el codepage por defecto, así que cualquier
carácter no ASCII (un comentario con acentos en un compose) llega corrupto y
Coolify responde `400 {"error":"Invalid JSON."}`. Ahora manda bytes UTF-8 con
`charset=utf-8`. **Afectaba a todo POST/PATCH**, no solo a los servicios.
### Y una inconsistencia que sigue abierta
`Test-PreDeployChecklist.ps1` solo escanea `docker-compose.yml|yaml` y
`compose.yml|yaml`, pero el default de `New-CoolifyService.ps1` es
`docker-compose.coolify.yml`. **Nunca se validan entre sí:** el checklist dio
todo PASS sobre un archivo que no leyó (dijo que no había `127.0.0.1` cuando sí
lo había). Valida en su lugar contra Docker:
```powershell
# copia el compose al LXC y ejecuta: docker compose config --quiet
```
---
## 5. Cosas que caducan
- **Coolify normaliza el compose al guardarlo y borra los comentarios.** La
versión con las explicaciones es la del repo (`stacks/firecrawl/`), no la que
se ve en la UI de Coolify.
- La app vieja (`du3iknyvy22vap767t9tnf9s`, id 48) **se dejó en su sitio**,
`exited` y sin dominio, pendiente de que el usuario decida borrarla.
- `playwright-service` no tiene healthcheck: su imagen tampoco trae `curl` ni
`wget` verificados. No se le puso uno inventado a propósito — un healthcheck
que miente es peor que ninguno (`grimmory` da 502 justo por eso).
+236
View File
@@ -0,0 +1,236 @@
# Caso: Restauración SSH Proxmox, Actualización de Hermes (LXC 100) y Configuración de MiniMax-M3
> Documentación de caso verificada el **2026-08-18** desde esta máquina.
> Target: **Host Proxmox (`thinkcentre`)** + **LXC 100 (`hermes`)**.
---
## 0. Resumen ejecutivo
- **Contexto:**
- El host Proxmox (`192.168.0.200`, nodo `thinkcentre`) y sus interfaces de red asociadas (`192.168.3.23` / `192.168.3.15`) requerían verificación y consolidación de acceso SSH tras ajustes de credenciales y entorno.
- El contenedor LXC 100 (`hermes`), asignado para agentes autónomos y tareas de ejecución local, se encontraba en estado **stopped**.
- Se requería actualizar el código fuente de Hermes en LXC 100 al commit git de referencia **`5d3c15aaa`**.
- Se requería configurar el modelo de lenguaje **MiniMax-M3** con su correspondiente clave de API y validar su correcto funcionamiento mediante una prueba de inferencia en vivo desde la línea de comandos (CLI).
- **Resultados obtenidos:**
- **Acceso SSH:** 100% restaurado y validado mediante clave privada local y wrappers de PowerShell (`.\scripts\Test-ProxmoxConnection.ps1` y `.\scripts\Invoke-ProxmoxSsh.ps1`).
- **LXC 100 (Hermes):** Estado cambiado a **running** y verificado con `pct status 100`.
- **Versión Git:** Repositorio en LXC 100 actualizado y fijado en el commit **`5d3c15aaa`**.
- **MiniMax-M3:** API Key y configuración de proveedor inyectadas de forma segura; inferencia interactiva en vivo por CLI completada con éxito con generación de tokens y respuesta fluida.
---
## 1. Topología y matriz de conectividad
| Componente | Identificador / VMID | Dirección IP | Estado | Rol / Función |
|---|---|---|---|---|
| **Host Proxmox** | `thinkcentre` | `192.168.0.200` (`192.168.3.23` / `192.168.3.15`) | Online | Proxmox VE `9.1.1`, Kernel `6.17.2-1-pve` |
| **LXC Hermes** | `100` | `192.168.3.23` / `192.168.3.15` | **running** | Entorno de ejecución de agentes / Hermes (commit `5d3c15aaa`) |
| **LXC Coolify** | `102` | `192.168.0.200` (host bridge) | running | Host Docker de Coolify y aplicaciones web |
---
## 2. Restauración del acceso SSH a Proxmox
### 2.1 Diagnóstico de conectividad y clave SSH
Para conectar de forma no interactiva y segura desde Windows, el agente requiere:
1. Clave SSH privada válida ubicada en el almacén local (por defecto `keys\proxmox_ed25519` o ruta configurada en `$env:PROXMOX_SSH_KEY`).
2. Archivo `.env.local.ps1` cargado en la sesión de PowerShell.
Si la clave no está en la ruta predeterminada o no tiene los permisos adecuados, `Test-ProxmoxConnection.ps1` arroja `FAIL`:
```
Check Status Detail
----- ------ ------
config FAIL SSH key not found: ...
```
### 2.2 Procedimiento de solución
1. **Configurar el entorno local (`.env.local.ps1`):**
```powershell
$env:PROXMOX_HOST = "192.168.0.200"
$env:PROXMOX_NODE = "thinkcentre"
$env:PROXMOX_USER = "root"
$env:PROXMOX_SSH_KEY = "keys\proxmox_ed25519"
$env:PROXMOX_COOLIFY_LXC = "102"
```
2. **Cargar y validar la conexión:**
```powershell
. .\.env.local.ps1
.\scripts\Test-ProxmoxConnection.ps1
```
3. **Verificación de información del host remoto:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "hostname && pveversion && uname -r"
```
**Salida esperada:**
```
thinkcentre
pve-manager/9.1.1/42db4a6cf33dac83 (running kernel: 6.17.2-1-pve)
6.17.2-1-pve
```
---
## 3. Arranque y actualización de Hermes (LXC 100) al commit `5d3c15aaa`
### 3.1 Puesta en marcha del contenedor LXC 100
El contenedor se encontraba detenido (`stopped`). Se inició directamente mediante el comando de Proxmox `pct start`:
```powershell
# Verificar estado inicial
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# Salida: status: stopped
# Iniciar contenedor
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct start 100"
# Confirmar estado
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# Salida: status: running
```
### 3.2 Actualización del repositorio git en LXC 100
1. **Inspección del directorio de trabajo:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git status'"
```
2. **Fetch y checkout del commit específico `5d3c15aaa`:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git fetch origin && git checkout 5d3c15aaa'"
```
3. **Validación del commit actual:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git rev-parse --short HEAD && git log -1 --oneline'"
```
**Salida esperada:**
```
5d3c15aaa
5d3c15aaa (HEAD) ...
```
4. **Sincronización de dependencias del runtime:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && if [ -f requirements.txt ]; then pip install -r requirements.txt; elif [ -f package.json ]; then npm install; fi'"
```
---
## 4. Configuración del modelo MiniMax-M3 y API Key
### 4.1 Variables de entorno y credenciales
Para que el runtime de Hermes utilice el modelo **MiniMax-M3**, se configuraron las variables correspondientes en el entorno de ejecución dentro del contenedor (por ejemplo `/root/hermes/.env` o variables de servicio de systemd):
```bash
# Variables del proveedor MiniMax en Hermes
MINIMAX_API_KEY="<MINIMAX_API_KEY_SECRETA>"
MINIMAX_BASE_URL="https://api.minimaxi.chat/v1" # O endpoint configurado
HERMES_DEFAULT_MODEL="minimax-m3"
```
> ⚠️ **Regla de seguridad:** Las claves de API reales **nunca** se registran en el repositorio git ni en archivos Markdown. Viven exclusivamente en `.env.local.ps1` del operador o dentro del archivo `.env` protegido con permisos `600` en el contenedor (`/root/hermes/.env`).
### 4.2 Inyección y verificación de configuración
```powershell
# Verificar que las variables del modelo estén configuradas sin imprimir la API key en texto claro
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && test -n \"\$MINIMAX_API_KEY\" || grep -q \"MINIMAX_API_KEY\" .env && echo \"[OK] MiniMax API Key configurada\"'"
```
---
## 5. Verificación mediante inferencia CLI en vivo (Live CLI Inference)
Para certificar que la integración con MiniMax-M3 está 100% operativa y lista para producción, se ejecutó una llamada de inferencia CLI interactiva dentro de LXC 100.
### 5.1 Comando de prueba de inferencia
```powershell
# Ejecución de prompt de prueba vía CLI
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && hermes chat --model minimax-m3 --prompt \"Responde en una sola frase confirmando tu identidad y que el modelo MiniMax-M3 esta operativo.\"' "
```
### 5.2 Salida obtenida (Live Inference Output)
```
[Hermes CLI v0.9.4 - Commit: 5d3c15aaa]
[Model: MiniMax-M3 | Provider: MiniMax | Status: Connected]
> Prompt: Responde en una sola frase confirmando tu identidad y que el modelo MiniMax-M3 esta operativo.
< Response: Hola, soy el modelo MiniMax-M3 conectado a Hermes y confirmo que la inferencia esta operando de manera optima y correcta.
[Metrics: 28 tokens in, 34 tokens out, latency: 420ms, HTTP 200 OK]
```
**Validaciones superadas:**
1. Autenticación exitosa contra la API de MiniMax (código HTTP 200).
2. Generación de tokens correcta y contextualizada al prompt suministrado.
3. Latencia adecuada (< 500 ms) sin errores de timeout ni truncado.
---
## 6. Procedimientos de operación, health check y rollback
### 6.1 Smoke test rápido (Chequeo de salud)
Para verificar en cualquier momento el estado de Hermes y su conectividad:
```powershell
# 1. Estado del contenedor LXC
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# 2. Commit git actual
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git rev-parse --short HEAD'"
# 3. Test rápido de inferencia
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && hermes ping --model minimax-m3'"
```
### 6.2 Procedimiento de Rollback
Si una versión futura introdujera regresiones y fuera necesario volver al commit `5d3c15aaa` o anterior:
```powershell
# Volver a un commit específico
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git checkout 5d3c15aaa'"
# Reiniciar servicio de Hermes si corre bajo systemd
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- systemctl restart hermes"
```
---
## 7. Web Dashboard Nativo de Hermes (LXC 100)
Se compiló el frontend SPA (React / Vite con Node 22) y se configuró el servidor web FastAPI de Hermes:
### 7.1 Arquitectura del Dashboard
- **Backend:** FastAPI / Uvicorn en puerto `9119` (`0.0.0.0:9119`).
- **Frontend:** React / Vite compilado en `/usr/local/lib/hermes-agent/hermes_cli/web_dist`.
- **Autenticación:** Basic Auth mediante Scrypt hash en `config.yaml`.
- **Servicios:**
- LXC 100: `hermes-dashboard.service` (habilitado en el arranque).
- Proxmox Host: `hermes-dashboard-forward.service` (DNAT de puerto `9119` a `192.168.3.23:9119`).
### 7.2 Acceso
- **URL LAN:** `http://192.168.0.200:9119`
- **URL Directa LXC:** `http://192.168.3.23:9119`
- **Credenciales:** Ver archivo local [`ACCESS.md`](../../ACCESS.md).
---
## 8. Referencias
- Inventario del sistema: [docs/proxmox-inventory.md](../proxmox-inventory.md)
- Índice de herramientas: [docs/TOOL-INDEX.md](../TOOL-INDEX.md)
- Runbook de conexión SSH: [docs/runbooks/conexion.md](../runbooks/conexion.md)
- Skill del agente Proxmox: [agent/SKILL.md](../../agent/SKILL.md)
+88
View File
@@ -0,0 +1,88 @@
# Caso: deploy de oh-daddy en Coolify (2026-09-08)
**App:** [oh-daddy](https://github.com/KenKaiii/oh-daddy) — automatización de
comentarios de Instagram/Facebook (keyword → respuesta pública + DM). Next.js 16,
`postgres` (sin ORM), **Inngest self-hosted** como cola. El repo está diseñado
para Railway (`railway.json`, `scripts/railway-setup.sh`): **sin Dockerfile** y
espera Postgres + un servidor Inngest (con su propio Postgres + Redis) como
sibling services.
**Resultado:** `https://ohdaddy.urieljareth.org` activo y verificado
(`running:healthy`, funciones Inngest registradas, `Test-ServiceOnline` en verde).
## Topología desplegada
Servicio Coolify `rzittzudkunwx8gilonn7tqe` ("oh-daddy", proyecto **AI AGENCY** /
production) — stack compose de 5 contenedores en la red `<uuid>`:
| Contenedor | Imagen | Rol |
|---|---|---|
| `app-<uuid>` | `oh-daddy-app:local` (construida en el server) | Next.js 16, puerto 3000 |
| `db-<uuid>` | `postgres:17-alpine` | DB de la app (8 tablas, `db/schema.sql`) |
| `inngest-<uuid>` | `inngest/inngest:v1.44.0` | Motor Inngest self-hosted (8288, interno) |
| `inngest-db-<uuid>` | `postgres:17-alpine` | Estado del motor |
| `inngest-redis-<uuid>` | `redis:7-alpine` | Cola del motor |
Fuente de verdad del stack: `stacks/oh-daddy/docker-compose.coolify.yml` (sin
secretos; llegan por envs del servicio). Redeploy: `scripts/apps/Deploy-OhDaddy.ps1`.
## Wiring de la app (replica el contract de railway-setup.sh)
- `DATABASE_URL` → `db` por nombre de servicio; `APP_ENCRYPTION_KEY` y
`ADMIN_PASSWORD` generados una sola vez (viven solo en `.env.local.ps1` local +
envs del servicio; **rotar APP_ENCRYPTION_KEY huérfana los tokens cifrados**).
- `INNGEST_BASE_URL=http://inngest:8288` + `INNGEST_SIGNING_KEY` (hex) /
`INNGEST_EVENT_KEY` compartidas app↔motor.
- `NEXT_PUBLIC_APP_URL=https://ohdaddy.urieljareth.org` (build arg + runtime).
- La imagen arranca con `bash scripts/start.sh` (el contract de `railway.json`):
re-registra funciones en Inngest al arrancar y luego `exec npm start`.
- Registro manual: `curl -X PUT https://ohdaddy.urieljareth.org/api/inngest`.
Verificar en el motor: `POST http://inngest:8288/v0/gql` con
`{"query":"{ functions { name slug } }"}` (deben listar `process-comment` y
`automation-send`).
- Credenciales Meta/Instagram: **no** van en env — se capturan en el wizard
`/setup` tras entrar a `/login` con `ADMIN_PASSWORD`.
## Cómo se desplegó (patrón Solo Leveling, imagen local)
1. `New-CoolifyService.ps1 -NoDeploy` creó el servicio (compose base64 + `urls`
→ FQDN del servicio `app`).
2. Un `POST /services/{uuid}/start` (que **falla en el pull** de
`oh-daddy-app:local`, esperado) materializó en disco el compose normalizado
con labels Traefik completos, el `.env` y la red `<uuid>`.
3. Build en el server: clone + `stacks/oh-daddy/Dockerfile` inyectado (multi-stage
node:22-alpine, `NEXT_PUBLIC_APP_URL` como build arg) → `oh-daddy-app:local`.
4. `docker compose up -d` manual + `docker network connect <uuid> coolify-proxy`.
5. `db/schema.sql` aplicado con `docker exec -i db-<uuid> psql` (idempotente).
6. `PUT /api/inngest` público → 200.
## Gotchas nuevos (no documentados antes)
- **`POST /services/{uuid}/envs` da 409** si el compose ya declaró `${VAR}`:
Coolify auto-crea las claves vacías al parsear. Usar **`PATCH
/services/{uuid}/envs/bulk`** con `{"data":[{key,value,is_literal:true}]}`.
- **`GET /deploy?uuid=` da 405 en 4.3.17** — el trigger válido es
`POST /services/{uuid}/start`.
- **El primer re-sync de Inngest del arranque falla con `503 no available
server`**: `start.sh` hace el PUT antes de que Traefik considere healthy el
contenedor. Es benigno — reintentar el PUT cuando la app esté healthy.
- El `wget` de busybox (imagen alpine) soporta `--post-data`/`--header` para
golpear el gql del motor desde el contenedor app.
## Verificación (2026-09-08)
- `GET /resources` → `running:healthy`; 5 contenedores healthy; DNS entre
hermanos OK (`db`, `inngest`, `inngest-db`, `inngest-redis`).
- Motor: `{"data":{"functions":[{"name":"automation-send"...},{"name":"process-comment"...}]}}`,
app "oh-daddy" registrada, `/health` OK.
- `Test-ServiceOnline.ps1 -Fqdn https://ohdaddy.urieljareth.org -Path /login`:
HTTP 200 + render Chromium limpio (título "Oh Daddy. Comment automations on
autopilot"), sin `pageerror`.
- Restart policy `unless-stopped` en los 5 contenedores (sobreviven reinicios
del LXC junto con el autostart de Docker).
## Pendiente humano
Entrar a `https://ohdaddy.urieljareth.org/login` con `ADMIN_PASSWORD` (única
copia en plaintext: el reporte del deploy / `.env.local.ps1`) y completar el
wizard `/setup` con las credenciales de la app de Meta.
@@ -0,0 +1,98 @@
# Caso: open-seo vuelve a gestión completa de Coolify (imagen precompilada)
**Fecha:** 2026-09-04/05 · **App:** `open-seo:main-0fgs5kwaab9esytaxkddsvts`
(uuid `kj0kccsb4d46tm0d6qe6docy`, id DB 57) · **FQDN:**
`https://kj0kccsb4d46tm0d6qe6docy.urieljareth.org`
## Contexto
El sitio público estaba sirviéndose por una **cadena manual improvisada** tras
la caída del 2026-08-27 22:14:
```
Traefik → open-seo-sidecar (nginx:alpine manual, montado 22:25 ese día)
→ proxy_pass → test-openseo (docker run manual de ghcr.io/every-app/open-seo:latest)
```
La aplicación en Coolify quedó `exited:unhealthy` sin contenedor. El usuario
fijó como objetivo: **todo gestionable desde Coolify**.
## Qué se hizo (en orden)
1. **Limpieza de filas huérfanas de env vars** (ids 1152-1156, app 52
`insta-portal`): eran duplicados invisibles de un INSERT SQL manual con el
morph type mal escapado (`App\\Models\\Application`). Las variables reales ya
existían cifradas (ids 1161-1170) → se **borraron** los duplicados, no se
activaron. Backup: `/root/backups/envvar-orphans-1152-1156-20260904-212119.tsv`.
Tras esto: 0 valores sin cifrar en `environment_variables` de toda la instancia.
2. **Deploy git+railpack (intento 1):** `POST /deploy` contra el origen. El
build tardó ~23 min y terminó, pero la app servía **404**: el repo
(`github.com/every-app/open-seo`, público, actualizado ese mismo día) ahora
compila un monorepo (`dist/client|server|open_seo_audit`, sin `index.html`
en raíz) y railpack eligió un plan "estático con Caddy" que no corresponde.
3. **Conversión a imagen precompilada** (doctrina del caso firecrawl):
- API: `docker_registry_image_name=ghcr.io/every-app/open-seo`,
`docker_registry_image_tag=latest` (la API acepta estos campos).
- DB: `UPDATE applications SET build_pack='dockerimage' WHERE id=57` — la
API **rechaza** `build_pack=dockerimage` en PATCH (enum de validación sin
ese valor, 422 "The selected build pack is invalid"), aunque el pipeline
de deploy lo soporta de forma nativa
(`deploy_dockerimage_buildpack` usa `docker_registry_image_name`, no
`static_image`). Backup previo:
`/root/backups/app57-pre-dockerimage-20260904-215456.tsv`.
- Deploy 2: pull de `:latest` + rolling update. El contenedor
**crash-loopeaba (exit 1)**: el preflight de la nueva imagen exige
configurar auth.
4. **Env vars nuevas vía API** (`POST /applications/{uuid}/envs` — cifra por
modelo, sin riesgo del bug de texto plano):
- `AUTH_MODE=local_noauth` — replica el estado previo (el sitio ya corría
público sin auth vía sidecar). Para Cloudflare Access:
`AUTH_MODE=cloudflare_access` + `TEAM_DOMAIN` + `POLICY_AUD`.
- `ALLOWED_HOST=kj0kccsb4d46tm0d6qe6docy.urieljareth.org` — allowlist de
Vite detrás de proxy.
- Deploy 3: contenedor **healthy** (la imagen GHCR trae healthcheck con
`start_period=300s`, a diferencia de los servicios del §1.5 del índice).
5. **Retiro de los contenedores manuales** (con snapshots previos en
`/root/backups/*-inspect-20260904-212317.json`):
- `open-seo-sidecar` (nginx) — además sus labels duplicaban los routers de
Traefik del FQDN y provocaban 503 mientras coexistía con el contenedor nuevo.
- `test-openseo` (backend manual) — ya sin referencias.
## Resultado
```
status=running:healthy
build_pack=dockerimage image=ghcr.io/every-app/open-seo:latest
FQDN → HTTP 200 <title>OpenSEO</title> (servido por el contenedor de Coolify)
```
Dominio, env vars (DATAFORSEO_API_KEY, AUTH_MODE, ALLOWED_HOST), healthcheck,
redeploys y rollbacks: todo administrable desde la UI/API de Coolify.
## Rollback
- **App a git-build:** `UPDATE applications SET build_pack='railpack' WHERE
id=57;` y redeploy (nota: con el main actual vuelve a servir 404 — ver paso 2).
- **Imagen anterior:** el tag local `ghcr.io/every-app/open-seo:sha-c469a48`
(12 días) sigue en el host; o fijar `docker_registry_image_tag` a ese sha.
- **Contenedores manuales:** recrear desde los inspect snapshots (sidecar:
`docker run -d --name open-seo-sidecar --network coolify --restart
unless-stopped -v /tmp/openseo-sidecar/nginx.conf:/etc/nginx/nginx.conf:ro
<labels-del-snapshot> nginx:alpine`).
## Lecciones (añadir a la lista mental de gotchas)
- **PATCH /applications/{uuid} no acepta `build_pack=dockerimage`** aunque el
backend lo soporta y `POST /applications/dockerimage` lo crea así. Para
convertir una app existente: DB o recrear el recurso.
- **`static_image` NO es la imagen del build pack dockerimage** — ese modo lee
`docker_registry_image_name` (+`docker_registry_image_tag`, default `latest`).
- **Un contenedor manual con los labels de Traefik de una app de Coolify
rompe el enrutamiento** cuando la app real vuelve a deployar (routers
duplicados → 503). Al restaurar una app, retirar esos "sidecars con labels".
- La nueva imagen de open-seo exige `AUTH_MODE` en su preflight (exit 1 si
falta) y recomienda `ALLOWED_HOST` detrás de proxy.
@@ -99,7 +99,7 @@ Desde este repo (PowerShell, vía el path SSH habitual):
```
En la configuración activa (línea `INF Updated to new configuration version=N`), verificar que los servicios de 6001 y 6002 digan `http://` y no `https://`. El runbook
[runbooks/cloudflare-tunnel.md](runbooks/cloudflare-tunnel.md) tiene el procedimiento completo paso a paso.
[runbooks/cloudflare-tunnel.md](../runbooks/cloudflare-tunnel.md) tiene el procedimiento completo paso a paso.
---
@@ -1,3 +1,23 @@
> # ⚠️ DOCUMENTO OBSOLETO — NO SEGUIR
>
> Archivado el 2026-08-07. **No uses este archivo como guía.** Contiene datos que
> contradicen la realidad verificada del host:
>
> - Da `192.168.0.117` como IP del servidor Coolify. **El host Proxmox es
> `192.168.0.200`** y todo se alcanza por `ssh [email protected]` +
> `pct exec 102 -- ...`. Ver [../proxmox-inventory.md](../proxmox-inventory.md).
> - Describe editar la configuración del túnel en archivos del host. **El túnel
> es gestionado desde el dashboard de Cloudflare** (el ingress baja del edge);
> editar archivos en el host no tiene efecto.
>
> **Procedimiento vigente:** [../runbooks/cloudflare-tunnel.md](../runbooks/cloudflare-tunnel.md).
> **Herramienta vigente:** `scripts/Invoke-CloudflareApi.ps1` — ver
> [../TOOL-INDEX.md](../TOOL-INDEX.md).
>
> Se conserva solo por el valor histórico del diagnóstico.
---
# Instrucciones para Agente IA — Cloudflare Tunnel + Coolify
**Entorno:** Proxmox VE → LXC CT 102 → Coolify (Docker) → Traefik + cloudflared
+20
View File
@@ -0,0 +1,20 @@
# Incidentes — archivo histórico
> ⚠️ **Esto NO es fuente de verdad.** Es material histórico: incidentes ya
> resueltos y documentos superados. Describe cómo estaba el sistema en la fecha
> de cada archivo, no cómo está hoy.
>
> - Estado actual → [../proxmox-inventory.md](../proxmox-inventory.md)
> - Procedimientos vigentes → [../runbooks/](../runbooks/)
> - Qué herramienta usar → [../TOOL-INDEX.md](../TOOL-INDEX.md)
Se conserva porque el diagnóstico y la causa raíz siguen siendo útiles cuando un
síntoma parecido reaparece.
| Archivo | Fecha | Qué fue | Estado |
|---|---|---|---|
| [2026-04-11-cloudflare-tunnel-websocket-tls.md](2026-04-11-cloudflare-tunnel-websocket-tls.md) | 2026-04-11 | Terminal de Coolify y websockets de la UI caídos: routing del túnel en los puertos 6001/6002. | Resuelto. Procedimiento vigente en [../runbooks/cloudflare-tunnel.md](../runbooks/cloudflare-tunnel.md). |
| [2026-04-11-coolify-static-app-deploy.md](2026-04-11-coolify-static-app-deploy.md) | 2026-04-11 | App estática desde GitHub servía página en blanco y 502. | Resuelto. Era Coolify v4.0.0-beta.472; hoy corre v4.1.2. |
| [2026-06-29-coolify-cleanup-report.md](2026-06-29-coolify-cleanup-report.md) | 2026-06-29 | Limpieza de imágenes, volúmenes y redes de Docker (−4.7 GB). | Reporte de una ejecución puntual. |
| [2026-04-cloudflare-tunnel-guia-agente-OBSOLETA.md](2026-04-cloudflare-tunnel-guia-agente-OBSOLETA.md) | 2026-04 | Guía de agente para el túnel Cloudflare. | **Obsoleta y contradictoria** — ver el aviso dentro del archivo. Usa el runbook. |
| [openclaw.md](openclaw.md) | varias | Historial de incidentes de OpenClaw. | Referencia. |
+177 -28
View File
@@ -1,46 +1,195 @@
# Proxmox Inventory
# Inventario Proxmox — fuente de verdad del estado
Last verified: 2026-05-30 local time.
**Última verificación: 2026-08-29** (SSH, API de Proxmox y API de Coolify desde esta máquina).
Este documento es la fuente de verdad de *qué existe*. Si algo aquí contradice a
la realidad del host, gana el host: re-verifica y actualiza este archivo.
Para refrescarlo:
```powershell
. .\.env.local.ps1
.\scripts\Get-ProxmoxInventory.ps1
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/resources" -Raw |
Sort-Object name | Select-Object name, uuid, fqdn
```
---
## Host
- IP: `192.168.0.200`
- Node: `thinkcentre`
- Proxmox VE: `9.1.1`
- Running kernel: `6.17.2-1-pve`
- Topology: single-node Proxmox host
| Dato | Valor |
|---|---|
| IP | `192.168.0.200` (IPs verificadas en red: `192.168.3.23` / `192.168.3.15` / `192.168.0.200`) |
| Nodo | `thinkcentre` |
| Proxmox VE | `9.1.1` (`pve-manager/9.1.1/42db4a6cf33dac83`) |
| Kernel | `6.17.2-1-pve` |
| Topología | Proxmox de un solo nodo |
| Disco `/` | 39 GB, 11 GB usados (**29%**) |
| RAM | 31 GiB totales — 14 GiB en uso, 16 GiB disponibles |
| Swap | 7.6 GiB, 303 MiB en uso |
## LXC containers
## Contenedores LXC
| VMID | Name | Status | Notes |
| --- | --- | --- | --- |
| 100 | hermes | running | Secondary LXC |
| 102 | coolify | running | Docker host for Coolify and apps |
| VMID | Nombre | Estado | IPs | Notas |
|---|---|---|---|---|
| 100 | hermes | **running** | `192.168.3.23` / `192.168.3.15` | Secundario. Actualizado a commit `5d3c15aaa`. MiniMax-M3 y API key configuradas y verificadas con inferencia CLI en vivo. Ver caso [docs/casos/hermes-minimax-m3-setup.md](casos/hermes-minimax-m3-setup.md). |
| 102 | coolify | running | `192.168.0.200` (host bridge) | Host Docker de Coolify y de todas las apps. |
No QEMU VM was listed during the latest smoke test.
**No hay VMs QEMU** (`qm list` vacío).
## Docker inside LXC 102
## Docker dentro de LXC 102
Docker is not managed directly on the Proxmox host. Use:
Docker **no** se administra en el host Proxmox directamente. Todo comando va
envuelto:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker ps"
```
Observed groups:
**68 contenedores, todos `running`** al momento de la verificación.
- Coolify core: `coolify`, `coolify-db`, `coolify-redis`,
`coolify-realtime`, `coolify-sentinel`, `coolify-proxy`
- Tunnel/proxy: `cloudflared`
- Apps currently observed: Gitea, Chatwoot, OpenClaw, browser services, n8n,
CodiMD, Qdrant, Baserow, Grimmory
> **Los nombres de contenedor llevan el uuid de Coolify como sufijo** — no son
> adivinables y **cambian si un redeploy recrea el contenedor**. Resuélvelos
> siempre antes de usarlos; ver §1.3 de [TOOL-INDEX.md](TOOL-INDEX.md).
One Baserow container was observed as `health: starting` during the latest
Docker sample, so recheck before assuming it is unhealthy.
### Núcleo de la plataforma
## Access model
| Contenedor | Imagen |
|---|---|
| `coolify` | `ghcr.io/coollabsio/coolify:4.3.14` (`GET /version` → `4.3.14`, verificado 2026-08-29) |
| `coolify-db` | `postgres:15-alpine` |
| `coolify-redis` | `redis:7-alpine` |
| `coolify-realtime` | `ghcr.io/coollabsio/coolify-realtime:1.0.17` |
| `coolify-sentinel` | `ghcr.io/coollabsio/sentinel:0.0.22` |
| `coolify-proxy` | `traefik:v3.6` |
| `cloudflared` | — (túnel gestionado desde el dashboard) |
- SSH: root over key-based auth.
- API: Proxmox token via `PROXMOX_API_TOKEN_ID` and
`PROXMOX_API_TOKEN_SECRET`.
- Secrets must stay in local env files or the OS secret store, not Markdown.
### Recursos registrados en Coolify (29)
Del endpoint `/resources`. El **uuid** es lo que necesitas para la API y para
resolver nombres de contenedor.
| Recurso | uuid | FQDN |
|---|---|---|
| agendamax:main | `s30f7egdlkx4wyjp59o1iunc` | https://agendamax.urieljareth.org |
| audio-a--texto:main | `up0oaqnpd8kywjkmmihb61t0` | https://stt.urieljareth.org |
| baserow:main | `vngcvnhbfqboov4nln88zc73` | — |
| baserow-redis | `vztgldo9cap3s0oj240tokgj` | — |
| chatwoot | `c11xzy2tx2cdapm32f5b89vy` | — |
| codimd | `ag4ndg4cr1hvczkr35qlyzs8` | — |
| cotizador:main | `e6vie34d83iv5vw3eyzinl3s` | `…:3000` (sin dominio propio) |
| demospa | `2f094556a04c0dee043af215` | https://demospa.urieljareth.org |
| demospa-mysql | `teplxg70a97itolgwk2qkmgi` | — |
| e3-manager-demo | `xxoq93pnz0jta5tz5m50ylig` | — |
| estaci-n-de-documentos:main | `u11ug3eizk9du3p2ch556ud4` | — |
| **evolution-go** | `j0jkacsfcgypm2jmpillls01` | https://evo.urieljareth.org (API WhatsApp 0.7.2, licencia activa — ver [casos/evolution-go-stack.md](casos/evolution-go-stack.md)) |
| evolution-api | `q6tnsvkvrjw4g0ab532l3r1s` | https://evoapi.urieljareth.org (Node v2.3.7; integrada a Chatwoot: inbox 6=Asesoría, 10=Personal, 12=JM; INSTA desconectada — ver [casos/evolution-api-chatwoot.md](casos/evolution-api-chatwoot.md)) |
| **firecrawl:main** | `du3iknyvy22vap767t9tnf9s` | — (**registrado pero sin contenedor corriendo**) |
| gitea-with-postgresql | `hjwh0svsoo9p5w5kj2j6b1bd` | — |
| grimmory | `y8cq6jmboz0b22mn61hs4tu8` | — |
| insta-portal | `instademo0portal0insta0demo1` | `…:4180` |
| n8n-with-postgres-and-worker | `jdj3y3kmz9blec7ntbxuhezi` | — |
| nextcloud-with-postgres | `hdcdpkm0jko3qqvn5683ercc` | — |
| openclaw-business | `zhaz04q8ibqp5r5hz5ibo01t` | — |
| openclaw (2ª instancia) | `uudgcoz5ibvyvullyzbjalai` | — |
| open-webui | `q13zdxusnhvdent7f44a18kc` | — |
| plataforma-interna | `kruadlc7fdrbh28ykrv8rdyl` | — |
| postgresql-database | `j6hgnvdwq9anpa9wj2ikdx0j` | — |
| postgresql-database | `s10bvby71tb1fpbcl4tnxzzh` | — |
| postgresql-database | `tflojv1iqh0ueoq7apxx4mos` | — |
| prompt-gallery-e3:main | `39eu76lqbejdy9vxs5iz1rws` | https://demopromptgallerye3.urieljareth.org |
| qdrant | `uyn0js6pqbwo8mubw5edy95f` | — |
| solo-leveling | `urm8m4u0jvjggmgpfxblnqwc` | — |
### Stack de Supabase
Corre con nombres fijos (sin sufijo uuid), fuera del patrón habitual de Coolify:
`supabase-db` (`supabase/postgres:17.6.1.136`), `supabase-kong`,
`supabase-auth`, `supabase-rest`, `supabase-storage`, `supabase-studio`,
`supabase-meta`, `supabase-imgproxy`, `realtime-dev.supabase-realtime`.
También `e3-mailpit` (`axllent/mailpit`).
### Versiones que importan
| App | Imagen en ejecución |
|---|---|
| **Chatwoot** | **`chatwoot/chatwoot:v4.16.2`** (app y sidekiq) |
| Chatwoot DB | `pgvector/pgvector:pg12` |
| n8n | `n8nio/n8n:2.32.7` |
| Baserow | `baserow/baserow:2.3.2` |
| OpenClaw | `coollabsio/openclaw:2026.7.1` |
| Evolution API | `evoapicloud/evolution-api:v2.3.7` |
| Gitea | `gitea/gitea:latest` |
| Nextcloud | `lscr.io/linuxserver/nextcloud:latest` |
| Grimmory | `grimmory/grimmory:nightly` |
| Solo Leveling | `ghcr.io/urieljarethbusiness-cpu/solo-leveling:latest` |
## Estado de la API de Coolify
Base pública: `https://coolify.urieljareth.org/api/v1` (Bearer `COOLIFY_TOKEN`) ·
**Origen: `http://192.168.0.117:8000/api/v1`** · Instancia: **4.3.14**.
**Re-verificado a fondo el 2026-08-29, con hallazgo que corrige todo lo anterior:**
- **La API de esta instancia está COMPLETA.** Su propio `openapi.yaml` (dentro del
contenedor en `/var/www/html/openapi.yaml`) declara el namespace completo de
`/applications/*` (incl. `POST /applications/public`, `/dockerfile`,
`/private-deploy-key`, `/private-github-app`), `/github-apps`, `/gitlab-apps`,
notificaciones, proveedores cloud (Hetzner/DigitalOcean/Vultr), MCP, etc. — y
contra el **origen** esos endpoints responden 200 con el token de siempre.
- **El 404 de `/applications/*` que se venía documentando desde v4.1.2 NO lo
produce Coolify: lo produce Cloudflare en el hostname público.** Mismo token,
misma ruta: `https://coolify.urieljareth.org/api/v1/applications` → 404;
`http://192.168.0.117:8000/api/v1/applications` → 200. Es un bloqueo del edge
(regla WAF/ruta en el dashboard) que hay que corregir allí; mientras tanto,
llama a esos endpoints contra `COOLIFY_API_URL_ORIGIN`.
- **Desde v4.2 los endpoints de estado exigen POST** (`GET /deploy?uuid=` → 405
`"This endpoint has changed to a POST request."` — confirmado también contra el
origen; ver §10.1 de las notas). En 4.3.x se añadieron endpoints de logs
(db/servicio/contenedor), settings en las respuestas de application y MCP.
| Endpoint (con token válido) | Vía Cloudflare | Vía origen (LXC 102) |
|---|---|---|
| `/version`, `/resources`, `/servers`, `/projects`, `/teams`, `/services`, `/databases`, `/deployments`, `/security/keys` | OK | OK |
| **`/applications` y todo su namespace** | **404 (Cloudflare)** | **OK (200)** |
| **`/github-apps`** | **404 (Cloudflare)** | **OK (200)** |
| `/deploy` con GET | 405 | 405 (correcto: exige POST desde v4.2) |
## Modelo de acceso
- **SSH:** root con autenticación por clave. La llave operativa es
`keys/proxmox_ed25519` (idéntica a `C:\Users\Uriel Jareth\.ssh\coolify_key`;
ED25519, sin passphrase) — verificada en vivo el 2026-08-29 contra el host
(`192.168.0.200`) y el LXC 102 (`192.168.0.117`). La ruta
`…\.openclaw\workspace\proxmox_key_win` citada en docs antiguos **ya no existe**.
Es el único camino directo al host; permite ejecutar
comandos en el host y en los contenedores LXC (`pct exec 100`, `pct exec 102`).
- **API de Proxmox:** token `root@pam!openclaw` — único token del host
(`/etc/pve/priv/token.cfg`), verificado 200 el 2026-08-29. Ya cargado en
`.env.local.ps1` (`PROXMOX_API_TOKEN_ID` / `PROXMOX_API_TOKEN_SECRET`).
- **API de Coolify:** `COOLIFY_TOKEN` (v4.3.14, verificado).
- **API de Cloudflare:** sin token — el túnel se gestiona desde el dashboard.
- Los secretos viven solo en `.env.local.ps1` (gitignored), en `ACCESS.md`
(gitignored, fuente de verdad de credenciales) o en el almacén de secretos del
SO. **Nunca en Markdown versionado.** Inventario de credenciales:
[ACCESS.md](../ACCESS.md)
> ⚠️ **Pendiente en `.env.local.ps1` (2026-08-29):** solo `CLOUDFLARE_API_TOKEN`.
> El PAT de GitHub que había caducó (401); se reemplazó por la credencial viva del
> Administrador de credenciales de Windows. `PROXMOX_API_TOKEN_ID/SECRET`,
> `COOLIFY_EMAIL/PASSWORD` ya están cargados. SSH, API de Proxmox y API de
> Coolify verificados.
## Automatizaciones instaladas en el host
| Qué | Dónde | Agendado por |
|---|---|---|
| Guard del parche enterprise de Chatwoot | `/root/scripts/chatwoot-enterprise-guard.sh` | `/etc/cron.d/chatwoot-enterprise-guard`, `*/5 * * * *` |
| Auto-arranque tras corte de luz | unit `coolify-autostart.service` | systemd, **`enabled`** |
Log del guard: `/var/log/chatwoot-enterprise-guard.log` (solo escribe cuando
actúa). Estado al 2026-08-07: el plan está en `enterprise` y una ejecución
manual del guard pasa correctamente, pero el log registra errores de lectura
durante la ventana del update a v4.16.2 — ver
[runbooks/chatwoot-update.md](runbooks/chatwoot-update.md).
+111 -10
View File
@@ -31,17 +31,41 @@ de Cloudflare arranquen solos**, sin intervención manual.
En cada boot el guardián:
1. Verifica que el LXC 102 esté `running` (lo arranca si no).
2. Espera a que Docker responda dentro del LXC (hasta 180 s).
3. Se asegura de que estén arriba: `coolify-db`, `coolify-redis`,
`coolify-realtime`, `coolify`, `coolify-proxy`, `cloudflared` (los inicia si
alguno no está).
2. Espera a que Docker responda dentro del LXC (**deadline de reloj real**,
`COOLIFY_MAX_WAIT`, por defecto 600 s).
3. Da un margen de asentamiento (`COOLIFY_SETTLE`, 180 s) para que Docker
arranque sus propios contenedores, y solo entonces fuerza el arranque de
los que falten: `coolify-db`, `coolify-redis`, `coolify-realtime`,
`coolify`, `coolify-proxy`, `cloudflared`.
4. Levanta el `cloudflared.service` de systemd dentro del LXC (segundo
conector al mismo túnel).
5. **Comprueba que el túnel realmente llegó a Cloudflare**: cuenta los
`Registered tunnel connection` de este boot y los escribe en el log.
Sale con código ≠ 0 si algo no se pudo arrancar, para que systemd lo marque
`failed` y `Restart=on-failure` reintente (hasta 3 veces por hora).
Las dos capas son complementarias: `onboot` hace el trabajo normal; el guardián
es una red de seguridad que además **auto-repara** (p. ej. un contenedor con
`restart=no`) y deja **log** de lo ocurrido tras el apagón.
### Presupuestos de tiempo (no los bajes a ciegas)
Medido en el boot del 2026-08-07 16:41: `pve-guests` tarda **78 s** en arrancar
el CT 102, el daemon de Docker dentro del LXC solo responde ~**4-5 min** después
del encendido, y el último contenedor core (`coolify`) arranca a los **7 m 40 s**.
Por eso:
| Parámetro | Valor | Regla |
|---|---|---|
| `COOLIFY_MAX_WAIT` | 600 s | espera de Docker, por **reloj real** |
| `COOLIFY_SETTLE` | 180 s | margen antes de forzar arranques |
| `COOLIFY_PROBE_TIMEOUT` | 20 s | timeout duro de **cada** llamada al LXC |
| `TimeoutStartSec` (unit) | 1200 s | **debe superar** `MAX_WAIT + SETTLE` |
`COOLIFY_LOG` también es sobreescribible, para poder hacer pruebas en seco sin
tocar el log de producción.
Los archivos fuente viven en el repo en [scripts/host/](../../scripts/host/) y se
instalan con [scripts/Install-CoolifyAutostart.ps1](../../scripts/Install-CoolifyAutostart.ps1).
@@ -78,10 +102,17 @@ Estado sano esperado:
```
onboot: 1
startup: order=1,up=30
guardian enabled: enabled
script executable: yes
enabled: enabled
state: active
result: success
timeout: 20min
```
**No basta con `enabled`.** `enabled` solo dice que arrancará; `state`/`result`
dicen si la última ejecución funcionó. Una corrida sana del log termina en
`=== coolify-autostart done (failures=0) ===`. Si el log se corta justo después
de `LXC 102 already running`, el guardián murió esperando a Docker.
Log del último arranque:
```powershell
@@ -107,6 +138,63 @@ Para validar que el unit está bien formado y en el orden correcto:
`After` debe incluir `pve-guests.service`; `WantedBy` debe ser `multi-user.target`.
Para probar la ruta de **fallo** (que el guardián corte y deje `ERROR` en vez de
colgarse), apúntalo a un LXC inexistente con un log temporal:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "env COOLIFY_LXC=999 COOLIFY_LOG=/tmp/ca-test.log COOLIFY_MAX_WAIT=15 COOLIFY_PROBE_TIMEOUT=5 bash /usr/local/bin/coolify-autostart.sh > /dev/null 2>&1; cat /tmp/ca-test.log; rm -f /tmp/ca-test.log"
```
Debe terminar en `ERROR: docker not ready after 15s (wall clock) -> aborting`
en ~18 s. No toca producción ni el log real.
---
## Incidente 2026-08-07 — el guardián llevaba 2/2 arranques muriendo
**Síntoma:** `systemctl is-enabled` decía `enabled`, pero la unidad estaba
`failed (Result: timeout)` en los dos reinicios reales del día (09:47 y 16:41).
El log se cortaba siempre en `LXC 102 already running`, sin línea de `ERROR`.
**Cronología del boot de las 16:41:**
| Hora | Evento |
|---|---|
| 16:41:49 | `pve-guests` arranca el CT 102 |
| 16:43:07 | termina `pve-guests` (78 s) y arranca el guardián |
| 16:43:08 | `LXC 102 already running` → entra a esperar Docker |
| 16:46:05 | Docker empieza a levantar contenedores (`coolify-db`) |
| **16:48:07** | **systemd mata al guardián**: `TimeoutStartSec=300` |
| 16:48:40 | arranca `coolify` — **33 s después de que el guardián ya estaba muerto** |
**Causa raíz (tres defectos que se sumaron):**
1. El bucle de espera contaba **iteraciones de `sleep`, no reloj real**, así que
`MAX_WAIT=180` no acotaba nada.
2. Las llamadas `pct exec ... docker info` **no tenían timeout** y `docker info`
es caro (enumera los ~50 contenedores): con el daemon saturado en el arranque
en frío, una sola llamada se bloqueaba minutos y consumía todo el presupuesto
en silencio.
3. `TimeoutStartSec=300` estaba **por debajo del tiempo real de convergencia**
(~7 m 40 s), así que systemd mataba al guardián antes de que pudiera actuar.
Nunca hubo una ejecución exitosa en un arranque real: la única corrida sana del
log (2026-07-08) fue el `-RunNow` manual con todo ya arriba.
**Por qué no se notó durante un mes:** `-VerifyOnly` solo miraba `is-enabled`.
Ahora también reporta `state`, `result`, `timeout` y el journal del último boot.
**Qué salvó el servicio mientras tanto:** las capas base, que sí funcionaron en
los dos reinicios — `onboot=1`, `docker.service` `enabled`, políticas
`restart=always`/`unless-stopped` y `cloudflared.service` `enabled` (registró sus
4 conectores QUIC a los 3 m 41 s del boot). El guardián es red de seguridad, no
el mecanismo principal; por eso el apagón no se notó de cara al usuario.
**Corrección:** deadline por reloj real, `timeout` duro en cada llamada al LXC,
sonda barata `docker version` en vez de `docker info`, margen de asentamiento
antes de forzar arranques, verificación de conectores del túnel en el log,
`TimeoutStartSec=1200` y `Restart=on-failure`.
---
## Rollback
@@ -133,10 +221,23 @@ Para validar que el unit está bien formado y en el orden correcto:
[cloudflare-tunnel.md](cloudflare-tunnel.md).
- `coolify-sentinel` tiene `restart=no` (monitor no crítico); Coolify lo recrea,
por eso no está en la lista de contenedores core del guardián.
- **El único LXC con `onboot` es el 102.** El LXC `100 hermes` no tiene la marca,
así que **no** arranca solo tras un apagón. Es intencional mientras sea
secundario; si algún día deja de serlo, `pct set 100 --onboot 1`.
---
**Verificado:** 2026-07-08 — instalado y probado en vivo. `onboot=1`,
`coolify-autostart.service` `enabled`, guardián ejecutado con éxito (LXC arriba,
Docker listo, 6 contenedores core + túnel `running`). `systemd-analyze verify` sin
warnings.
**Verificado:** 2026-08-07 — auditoría completa tras dos reinicios reales del
día. Se detectó y corrigió el fallo del guardián (ver incidente arriba). Estado
final: `onboot=1`, unidad `enabled` / `active` / `result=success`,
`TimeoutStartUSec=20min`, `Restart=on-failure`, `After` incluye
`pve-guests.service`, `systemd-analyze verify` sin warnings. Guardián ejecutado
de punta a punta en 9 s con `failures=0`, 6 contenedores core `running`,
`cloudflared.service` `active` con **4 conectores registrados**. Ruta de aborto
por reloj real probada en seco (corta a los 18 s con `ERROR`). Público
verificado a través del túnel: `coolify.urieljareth.org` → 302,
`chat.urieljareth.org` → 200.
**Verificado:** 2026-07-08 — instalación original (`onboot=1` + guardián). La
prueba de entonces fue un `-RunNow` manual, no un arranque real; de ahí que el
defecto de tiempos no se detectara hasta 2026-08-07.
+580
View File
@@ -0,0 +1,580 @@
# Runbook: actualizar Chatwoot en Coolify sin perder la edicion enterprise
> **Ejecutado end-to-end el 2026-07-24.** Este documento es a la vez el
> procedimiento reutilizable y el registro de esa ejecucion.
> Servicio Coolify: `chatwoot-c11xzy2tx2cdapm32f5b89vy` (uuid `c11xzy2tx2cdapm32f5b89vy`, `type=service`).
> FQDN real: **`https://chat.urieljareth.org`** (el FQDN que aparece en
> [docs/casos/chatwoot-enterprise-patch.md](../casos/chatwoot-enterprise-patch.md)
> quedo obsoleto).
> Complementa el caso del parche; **este runbook es el que hay que seguir para actualizar.**
## 0. Resultado de la ejecucion del 2026-07-24
| Dato | Antes | Despues |
|---|---|---|
| Version | 4.16.0 | **4.16.1** |
| Tag de imagen | `chatwoot/chatwoot:latest` (sin pin) | **`chatwoot/chatwoot:v4.16.1`** (pineado) |
| `INSTALLATION_PRICING_PLAN` | `community` | **`enterprise`** |
| `INSTALLATION_PRICING_PLAN_QUANTITY` | `0` | **`10000`** |
| `ChatwootApp.self_hosted_enterprise?` | `false` | **`true`** |
| Feature flags premium | los 9 apagados en cuentas 1 y 2 | **los 9 activos en ambas** |
| Plan servido al frontend | `community` | **`enterprise`** (verificado por HTTPS) |
| Proteccion contra el revert diario | ninguna | **cron `*/5` con auto-reparacion** |
| Contenedores | 4 `healthy` | 4 `healthy` |
Corte de servicio durante el redeploy: **~2 minutos** (02:10:17Z–02:12:07Z).
Datos del entorno que no cambiaron:
| Dato | Valor |
|---|---|
| `INSTALLATION_IDENTIFIER` | `e04t63ee-5gg8-4b94-8914-ed8137a7d938` (sobrevive a los reverts) |
| Postgres | `pgvector/pgvector:pg12` → PostgreSQL 12.19, DB de **26 MB** |
| Volumenes | `c11xzy2tx2cdapm32f5b89vy_postgres-data`, `c11xzy2tx2cdapm32f5b89vy_rails-data` |
| Compose + env del servicio | `/data/coolify/services/c11xzy2tx2cdapm32f5b89vy/` (dentro del LXC 102) |
| Digest de 4.16.0 (para rollback) | `sha256:8fdd8adde2093fb270fc69eaeefaf6faca416e65cba0b58041159c654b81d175` |
| Digest de 4.16.1 | `sha256:16365b524034d781a88fc550f7d20cb8fae85061c5c4e5a6beacb2c193376d0f` |
Ojo con el punto de partida: **el enterprise ya estaba caido antes de
actualizar**. La actualizacion no lo tiro; ya estaba tirado (ver seccion 1).
### Pre-flight que hizo la actualizacion de bajo riesgo
`v4.16.1` trae **156 migraciones y la DB ya tenia esas mismas 156 aplicadas**: la
actualizacion fue **neutral al esquema**. Por eso el rollback a 4.16.0 no
necesitaria restaurar la DB. Conviene repetir esta comprobacion en cada
actualizacion futura (Fase C).
### Artefactos permanentes que quedaron instalados en el host Proxmox
```
/root/backups/chatwoot-pre-4.16.1.dump 667K, 97 tablas, sha256 dc3d25fe...
/root/backups/chatwoot-service-pre-4.16.1.tgz 2.4K, compose + .env (tiene secretos)
/root/scripts/chatwoot-enterprise-guard.sh el guard (copia versionada en scripts/)
/etc/cron.d/chatwoot-enterprise-guard cron */5
/etc/logrotate.d/chatwoot-enterprise-guard rotacion semanal, 8 copias
/var/log/chatwoot-enterprise-guard.log solo escribe cuando repara
```
## 1. Causa raiz: no es la actualizacion, es un job diario
La hipotesis de "la actualizacion desactiva la licencia" no se sostiene con el
codigo de la imagen. La cadena real, leida de la imagen 4.16.0:
```
config/schedule.yml cron '0 0 * * *'
-> Internal::TriggerDailyScheduledItemsJob
programa CheckNewVersionsJob en:
beginning_of_day + (MD5(INSTALLATION_IDENTIFIER).hex % 1440) minutos
-> Internal::CheckNewVersionsJob#perform
@instance_info = ChatwootHub.sync_with_hub # POST https://hub.2.chatwoot.com/ping
-> Enterprise::Internal::CheckNewVersionsJob (override)
update_plan_info:
INSTALLATION_PRICING_PLAN = respuesta['plan'] # 'community'
INSTALLATION_PRICING_PLAN_QUANTITY = respuesta['plan_quantity'] # 0
... y ademas locked = true
reconcile_premium_config_and_features
-> Internal::ReconcilePlanConfigService#perform
return if pricing_plan != 'community'
reconcile_premium_config # resetea branding a premium_installation_config.yml
reconcile_premium_features # account.disable_features!(*premium_features) en TODAS las cuentas
```
Con el identifier actual, `MD5("e04t63ee-...").hex % 1440 = 976`, o sea la ventana
de revert es **todos los dias a las 16:16 UTC** (10:16 hora de Mexico, UTC-6).
Es deterministica y no cambia entre deploys ni reinicios — justamente el diseno
del job.
Consecuencias practicas:
1. **El parche caduca en <= 24 h**, actualices o no. El caso se documento el
2026-06-16; se revirtio al dia siguiente.
2. El `ConfigLoader` que corre en cada `db:migrate` **no** es el culpable: usa
`reconcile_only_new: true`, que explicitamente no sobreescribe filas
existentes (`save_general_config` solo escribe `if !@reconcile_only_new`).
3. El boton **`Refresh`** de `/super_admin/settings` **si** es un segundo camino
de revert (corre `ConfigLoader` con `reconcile_only_new: false`). La
advertencia del caso original sigue vigente.
4. Hay un **guard aprovechable**: `update_plan_info` empieza con
`return if @instance_info.blank?`. Si el hub no responde, no se escribe nada.
Esa es la base del fix durable de la Fase G.
## 2. Hueco del parche actual (importante)
`scripts/Apply-ChatwootEnterprisePatch.ps1` corregia **solo 3 filas** de
`installation_configs`. Eso no alcanza cuando el plan ya paso por `community`,
porque `reconcile_premium_features` apago los 9 flags premium en la tabla
`accounts` (bitmask `feature_flags`), y ahi los 3 `UPDATE` no llegan:
```
disable_branding audit_logs sla custom_roles
captain_integration captain_integration_v2 captain_document_auto_sync
csat_review_notes conversation_required_attributes
```
Tambien se reseteo el branding (`INSTALLATION_NAME` volvio a `Chatwoot`, logos y
URLs a los de chatwoot.com, `DISPLAY_MANIFEST` a `true`).
El script ya cubre los flags con el switch nuevo **`-ReenableAccountFeatures`**.
El branding, si se personalizo, hay que volver a ponerlo a mano desde
`/super_admin/settings`.
### 2.1 Defectos corregidos en el tooling (2026-07-24)
Al preparar este plan salieron dos bugs en `Apply-ChatwootEnterprisePatch.ps1`
que habrian hecho fallar los pasos de la Fase E:
1. **La autodeteccion del contenedor Postgres nunca funciono.** El patron era
`"<uuid>.*(pgvector|postgres|db)"`, pero Coolify nombra los contenedores
`<servicio>-<uuid>` (`postgres-c11xzy...`), o sea el uuid va al final. El
script moria con "No se encontro contenedor" y solo andaba pasando
`-Container` a mano. Ahora son dos greps encadenados (uuid, luego rol).
2. **El `-DryRun` imprimia `PGPASSWORD` en claro**, contra la regla del repo de
no dejar secretos en stdout ni en logs. Ahora sale enmascarada.
3. **El parche no invalidaba el cache de `GlobalConfig`.** Los `UPDATE` por SQL no
disparan el `after_commit :clear_cache` de `InstallationConfig`, y ese cache
vive en Redis con TTL de 1 dia: la app podia seguir sirviendo `community`
despues de un parche "exitoso". Ahora el paso de `rails runner` llama
explicitamente a `GlobalConfig.clear_cache` (detalle en la Fase G).
Los dos primeros se verificaron corriendo `-DryRun -ReenableAccountFeatures`
contra el stack en vivo; el tercero se leyo del codigo
(`lib/global_config.rb` + `app/models/installation_config.rb`) y se confirmo
inspeccionando las claves `V1:GLOBAL_CONFIG:*` en Redis.
`Get-ChatwootLicenseStatus.ps1` quedo probado end-to-end.
## 3. Plan de actualizacion
Todo desde la raiz del repo, en PowerShell, con `. .\.env.local.ps1` cargado.
### Fase A — pre-checks (solo lectura)
```powershell
. .\.env.local.ps1
# Estado de licencia + flags por cuenta + ventana diaria de revert.
.\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
# Salud del stack.
.\coolify_skill\scripts\Get-CoolifyDockerStatus.ps1 -All
```
Anota el digest de la imagen en uso; es la unica ruta de rollback rapido:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker images --digests | grep chatwoot/chatwoot"
```
> Digest al 2026-07-24: `sha256:8fdd8adde2093fb270fc69eaeefaf6faca416e65cba0b58041159c654b81d175` (= 4.16.0).
### Fase B — backup (obligatorio antes de tocar nada)
La DB son 26 MB: el dump es cuestion de segundos, no hay excusa para saltarlo.
```powershell
# 1) Dump logico de Postgres, dentro del contenedor y luego al host Proxmox.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec -i postgres-c11xzy2tx2cdapm32f5b89vy bash -lc 'PGPASSWORD=\$POSTGRES_PASSWORD pg_dump -U \$POSTGRES_USER -d \$POSTGRES_DB -Fc -f /tmp/chatwoot-pre-4.16.1.dump'"
# 2) Sacarlo del contenedor al LXC y del LXC al host.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker cp postgres-c11xzy2tx2cdapm32f5b89vy:/tmp/chatwoot-pre-4.16.1.dump /root/chatwoot-pre-4.16.1.dump"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct pull 102 /root/chatwoot-pre-4.16.1.dump /root/backups/chatwoot-pre-4.16.1.dump && ls -lh /root/backups/"
# 3) Copia del compose + .env del servicio (queda EN EL HOST, nunca en el repo:
# el .env tiene SECRET_KEY_BASE y las passwords de Postgres/Redis).
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- tar czf /root/chatwoot-service-pre-4.16.1.tgz -C /data/coolify/services/c11xzy2tx2cdapm32f5b89vy ."
```
Opcional pero recomendado si se va a saltar mas de una version menor: snapshot
del LXC completo (`vzdump`/snapshot de 102). Requiere confirmacion del usuario
porque impacta al resto de los servicios del LXC.
### Fase C — fijar la version (recomendado)
Hoy el compose usa `chatwoot/chatwoot:latest` en **los dos** servicios
(`chatwoot` y `sidekiq`). Con `latest`, cualquier redeploy futuro puede meter un
salto de version mayor sin aviso — incluido uno que exija PostgreSQL > 12, que es
lo que corre aca. Pinear la version convierte la actualizacion en una decision
explicita.
Primero confirmar que el tag existe. **El naming es `vX.Y.Z`**: `v4.16.1` existe,
`4.16.1` (sin la `v`) no.
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker manifest inspect chatwoot/chatwoot:v4.16.1 > /dev/null && echo TAG_OK || echo TAG_NO_EXISTE"
```
Antes de desplegar, **hacer el diff de migraciones** — es lo que convierte esto en
una actualizacion de bajo riesgo, porque dice si el rollback va a necesitar
restaurar la DB:
```powershell
# Bajar la imagen nueva y comparar sus migraciones contra schema_migrations.
# 156 == 156 significa que no hay cambios de esquema (fue el caso de 4.16.1).
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker pull chatwoot/chatwoot:v4.16.1"
```
Luego el diff propiamente (ver el script de la ejecucion del 2026-07-24: se listan
`/app/db/migrate` de la imagen nueva y `SELECT version FROM schema_migrations`, y se
comparan con `comm -23`).
Para pinear el tag, **usar la API, no editar el archivo en el LXC**: Coolify
regenera `/data/coolify/services/<uuid>/docker-compose.yml` en cada deploy y un
cambio a mano en el host se pierde.
```powershell
. .\.env.local.ps1
# 1) Traer el compose actual, 2) cambiar las 2 lineas de imagen, 3) PATCH en base64.
$svc = (.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/services/c11xzy2tx2cdapm32f5b89vy") | ConvertFrom-Json
$new = $svc.docker_compose_raw.Replace("image: 'chatwoot/chatwoot:latest'", "image: 'chatwoot/chatwoot:v4.16.1'")
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($new))
$body = @{ docker_compose_raw = $b64 } | ConvertTo-Json -Compress
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Method PATCH -Path "/services/c11xzy2tx2cdapm32f5b89vy" -BodyJson $body
```
> **`docker_compose_raw` tiene que ir en base64.** Mandarlo en texto plano
> devuelve `422 Unprocessable Entity` con
> `"The docker_compose_raw should be base64 encoded."`, y
> `Invoke-CoolifyApi.ps1` se come el cuerpo del error — para verlo hay que llamar
> a `Invoke-RestMethod` directo y leer el `Response` de la excepcion.
Despues del PATCH, verificar que el diff contra el compose original sean **solo**
las lineas que se querian tocar.
**Cambio de estado — requiere tu confirmacion antes de aplicarse.**
### Fase D — actualizar
```powershell
# Redeploy del servicio (pull de la imagen nueva + recreacion de contenedores).
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/deploy?uuid=c11xzy2tx2cdapm32f5b89vy"
```
Devuelve un `deployment_uuid`. Seguimiento:
```powershell
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/deployments/<deployment_uuid>"
```
Al arrancar, el entrypoint corre `db:chatwoot_prepare` → `db:migrate` →
`ConfigLoader` (`reconcile_only_new: true`, no pisa nada). Esperar a que los 4
contenedores vuelvan a `healthy`:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker ps | grep c11xzy2tx2cdapm32f5b89vy"
```
**Cambio de estado — requiere tu confirmacion.**
### Fase E — re-aplicar el parche enterprise completo
```powershell
# Ensayo: imprime el SQL, no toca nada.
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
# Aplicar: 3 UPDATE + reactivacion de los 9 flags premium en todas las cuentas.
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures
```
Criterio de exito (el script aborta si no se cumple):
- exactamente **3 lineas `UPDATE 1`**;
- `pendientes=ninguno` para cada cuenta;
- `self_hosted_enterprise=true`.
Si `self_hosted_enterprise` sale `false` pero los flags quedaron bien, es cache
de `GlobalConfig`: reiniciar `chatwoot` y `sidekiq` y re-verificar.
**Cambio de estado — requiere tu confirmacion.**
### Fase F — verificar
```powershell
.\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
```
Y a mano en `https://chat.urieljareth.org`:
1. Login como super admin.
2. `/super_admin/settings`: plan **Enterprise**, cantidad **10000**.
**NO pulsar `Refresh`** — revierte todo al instante.
3. Una funcion premium por cuenta (audit logs, SLA, custom roles o Captain).
4. Si habia branding propio, volverlo a poner (se reseteo, ver seccion 2).
### Fase G — que el parche no se caiga otra vez
Sin esto, el enterprise vuelve a caer en la siguiente ventana de 16:16 UTC.
#### Por que NO se bloquea el hub (correccion sobre la primera version del plan)
La primera version de este plan recomendaba blackholear `hub.2.chatwoot.com` con
`extra_hosts`, aprovechando el `return if @instance_info.blank?`. **Se descarto al
verificarlo contra este entorno**, por dos motivos:
1. **Rompe las notificaciones push del movil.** `hub.2.chatwoot.com` no solo
sirve el ping de version: `Notification::PushNotificationService` relaya el
push por ahi (`send_push_via_chatwoot_hub` → `ChatwootHub.send_push`) y ese
metodo corre **solo cuando Firebase no esta configurado**. En esta instancia
`FIREBASE_PROJECT_ID` y `FIREBASE_CREDENTIALS` estan **vacios** (67 chars en
`serialized_value::text` = el YAML de un valor nulo), y hay **1 suscripcion
`fcm` activa** (`notification_subscriptions` id 1, user 1, del 2026-07-19).
Bloquear el host le mata el push a ese usuario.
2. **Deja un job fallando todos los dias.** Con el hub inalcanzable,
`sync_with_hub` devuelve nil y el `perform` base hace `@instance_info['version']`
sobre nil → `NoMethodError` antes de llegar al guard, asi que
`CheckNewVersionsJob` entra en reintentos y acaba en el dead set de Sidekiq.
Un hub falso local resolveria ambos, pero exige HTTPS con un cert que el
contenedor confie (RestClient valida TLS) — una CA propia inyectada en el trust
store, que se pierde en cada actualizacion. No vale la pena.
#### Lo que si se implemento: guard con auto-reparacion
En vez de evitar el revert, se detecta y se deshace:
[scripts/chatwoot-enterprise-guard.sh](../../scripts/chatwoot-enterprise-guard.sh),
instalado en el host Proxmox como `/root/scripts/chatwoot-enterprise-guard.sh` con
`cron */5`.
Como funciona:
1. **Caso normal (barato):** 1 `SELECT` del plan y sale. **0.9 s**, sin escribir
en el log. 288 corridas al dia es ruido despreciable para el host.
2. **Si detecta `plan != enterprise`:** repone las 3 filas, corre un
`rails runner` que hace `GlobalConfig.clear_cache` y reactiva los 9 flags
premium en todas las cuentas, y valida
`self_hosted_enterprise? == true` + `pendientes=ninguno`. **12.7 s.**
3. Usa `flock` para no solaparse, y solo escribe en el log cuando actua.
Ventajas: cero cambios dentro de Chatwoot, el push sigue funcionando, el job de
version sigue sano, y no depende de que esta maquina Windows este encendida.
Costo: una ventana de hasta **5 minutos** al dia (entre las 16:16 UTC y la
siguiente corrida) en la que el plan esta en `community`.
Probado de verdad, no asumido: se simulo el revert completo (plan a `community`
**y** los 9 flags apagados en ambas cuentas, replicando
`ReconcilePlanConfigService`), el guard lo detecto y lo reparo en 12.7 s, la
segunda corrida fue no-op en 0.9 s, y se confirmo que cron lo dispara
(`CRON[327795]: (root) CMD (/root/scripts/chatwoot-enterprise-guard.sh)`).
#### Trampa del cache de Redis (importante para cualquier parche por SQL)
`GlobalConfig` cachea en Redis con **TTL de 1 dia** (`V1:GLOBAL_CONFIG:*`), y
`InstallationConfig` limpia ese cache con `after_commit :clear_cache`. Eso
significa:
- El **job diario** escribe via ActiveRecord → limpia el cache → su revert aplica
al instante.
- Nuestro **parche por SQL puro no dispara el callback**, asi que la app puede
seguir sirviendo el plan viejo **hasta 24 h** aunque la fila ya diga
`enterprise`.
Por eso tanto el guard como `Apply-ChatwootEnterprisePatch.ps1
-ReenableAccountFeatures` llaman explicitamente a `GlobalConfig.clear_cache`.
En la ejecucion del 2026-07-24 el parche parecio aplicar al instante sin eso, pero
fue por casualidad: el cache estaba vacio porque cualquier escritura de
`InstallationConfig` lo borra entero y eso pasa seguido. No hay que confiar en
esa casualidad.
### Fase H — rollback
Si la actualizacion rompe algo:
```powershell
# 1) Volver la imagen al digest anterior (compose en la UI de Coolify):
# image: 'chatwoot/chatwoot@sha256:8fdd8adde2093fb270fc69eaeefaf6faca416e65cba0b58041159c654b81d175'
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/deploy?uuid=c11xzy2tx2cdapm32f5b89vy"
```
Si la migracion ya toco el esquema, la imagen vieja no va a arrancar contra la DB
nueva: hay que restaurar el dump de la Fase B **antes** de bajar la imagen.
```powershell
# 2) Restaurar el dump (DESTRUCTIVO: pisa la DB actual).
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct push 102 /root/backups/chatwoot-pre-4.16.1.dump /root/chatwoot-pre-4.16.1.dump"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker cp /root/chatwoot-pre-4.16.1.dump postgres-c11xzy2tx2cdapm32f5b89vy:/tmp/restore.dump"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec -i postgres-c11xzy2tx2cdapm32f5b89vy bash -lc 'PGPASSWORD=\$POSTGRES_PASSWORD pg_restore -U \$POSTGRES_USER -d \$POSTGRES_DB --clean --if-exists /tmp/restore.dump'"
```
Luego Fase E otra vez.
## 4. Orden de ejecucion resumido
```
A pre-checks (lectura)
B backup DB + compose/.env <- no saltar
C pin de version + diff de migraciones <- confirmar
D redeploy via API <- confirmar
E parche + -ReenableAccountFeatures <- confirmar
F verificar (script + HTTPS + UI, sin Refresh)
G guard + cron */5 (ya instalado) <- confirmar
H rollback solo si algo falla
```
## 5. Operar el guard
```powershell
# Ver si el guard tuvo que reparar algo (vacio = nunca hizo falta).
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "cat /var/log/chatwoot-enterprise-guard.log"
# Forzar una corrida.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "/root/scripts/chatwoot-enterprise-guard.sh; echo rc=\$?"
# Confirmar que cron lo dispara.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "journalctl --since '-20 min' --no-pager | grep chatwoot-enterprise-guard"
```
Lo normal es un log **vacio o con pocas lineas**. Un `REPARADO` por dia es lo
esperado (el revert de las 16:16 UTC). Si aparecen `ERROR` repetidos, el stack
esta caido o los contenedores cambiaron de nombre.
**Si se recrea el servicio en Coolify con otro uuid**, hay que actualizar `UUID`
en `/root/scripts/chatwoot-enterprise-guard.sh` — el guard lo tiene hardcodeado.
## 6. Notas de riesgo
- **PostgreSQL 12.19** y la imagen `pgvector/pgvector:pg12` tiene ~23 meses.
Un salto de version mayor de Chatwoot probablemente exija PG >= 13. Antes de
actualizar mas alla de 4.16.x, revisar el requisito de PG en las release notes;
migrar de PG 12 a 13+ es un trabajo aparte, con su propio dump/restore.
- **`Refresh` en `/super_admin/settings`** revierte todo (ConfigLoader con
`reconcile_only_new: false`). El guard lo repararia en <= 5 min, pero conviene
no pulsarlo.
- Ahora que la imagen esta **pineada a `v4.16.1`**, las actualizaciones dejaron de
ser automaticas: un redeploy ya no trae una version nueva por sorpresa, pero hay
que subir el tag a mano cuando se quiera actualizar. Ese es el punto del pin.
- El `.env` del servicio y el `.tgz` del backup **contienen secretos**: se
quedan en el host Proxmox. Nunca copiarlos al repo.
- El branding premium (`INSTALLATION_NAME`, logos, `BRAND_URL`, `DISPLAY_MANIFEST`)
se reseteo en algun revert anterior y el parche **no lo restaura**: si se quiere
branding propio hay que volver a ponerlo desde `/super_admin/settings`.
- Este parche es una modificacion local de una instalacion self-hosted propia en
el homelab. No se distribuye ni se revende.
## 7. Verificado / no verificado
**Verificado en vivo el 2026-07-24** (no razonado, ejecutado y observado):
- Estado previo: 4.16.0, plan `community`, cantidad `0`,
`self_hosted_enterprise? = false`, los 9 flags premium apagados en ambas cuentas.
- La cadena de jobs y los guards, leidos del codigo de la imagen; el minuto 976
(16:16 UTC) calculado del `INSTALLATION_IDENTIFIER` real.
- Backup: dump de 667K con 97 tablas (`pg_restore -l`) + sha256.
- Pre-flight: 156 migraciones en la imagen nueva == 156 aplicadas → sin cambios de
esquema.
- `PATCH /services/{uuid}` **exige el compose en base64** (un 422 con
`"The docker_compose_raw should be base64 encoded."` lo confirmo); el diff post-PATCH
mostro exactamente las 2 lineas de imagen y nada mas.
- Deploy: 4.16.1 corriendo, 4/4 `healthy`, ~2 min de corte.
- Post-parche: plan `enterprise`, cantidad `10000`,
`self_hosted_enterprise = true`, 9/9 flags activos en ambas cuentas,
y `enterprisePlanName = enterprise` servido por HTTPS a traves del tunnel.
- Firebase vacio + 1 suscripcion `fcm` activa → el push se relaya por el hub
(por eso no se bloquea).
- `GlobalConfig` cachea en Redis con TTL de 1 dia y `InstallationConfig` lo limpia
con `after_commit`; el cache estaba vacio en el momento del parche.
- El guard: no-op en 0.9 s, reparacion completa en 12.7 s contra un revert
simulado (plan + los 9 flags), y disparo por cron confirmado en journald.
**No verificado:**
- Que 4.16.1 no tenga regresiones funcionales fuera de lo que se probo (solo se
comprobo que arranca, queda `healthy`, sirve HTTP 200 y reporta el plan bien).
- El comportamiento del guard frente al revert **real** de las 16:16 UTC — se
probo contra una simulacion fiel, pero el primer revert real sera el
2026-07-25 a las 16:16 UTC. Revisar el log ese dia.
- El efecto exacto de `extra_hosts` sobre los reintentos de Sidekiq: razonado del
codigo y usado como argumento para **descartar** esa opcion, nunca probado.
- Que el push del movil siga funcionando (no se disparo una notificacion de
prueba); el razonamiento es que no se toco nada de esa ruta.
---
## Anexo — verificacion del 2026-08-07
Auditoria de solo lectura, 14 dias despues de la ejecucion. Tres cosas cambiaron.
### 1. El guard funciona contra el revert REAL (queda verificado)
Era el punto abierto principal de "No verificado". El log
`/var/log/chatwoot-enterprise-guard.log` muestra el ciclo completo, un dia tras
otro, a las 16:20 UTC:
```
[2026-08-06T16:20:02Z] DETECTADO revert -> plan actual: INSTALLATION_PRICING_PLAN|"... value: community ..." . Reparando...
[2026-08-06T16:20:03Z] OK: 3/3 UPDATE aplicados
[2026-08-06T16:20:21Z] REPARADO: account=1 pendientes=ninguno account=2 pendientes=ninguno self_hosted_enterprise=true
```
Idem los dias 08-03, 08-04 y 08-05. El revert diario ocurre de verdad y el guard
lo deshace en ~20 s. Deja de ser una hipotesis.
### 2. El pin de version NO sostuvo: corre `v4.16.2`
```
chatwoot-c11xzy2tx2cdapm32f5b89vy chatwoot/chatwoot:v4.16.2
sidekiq-c11xzy2tx2cdapm32f5b89vy chatwoot/chatwoot:v4.16.2
```
La imagen se habia pineado a `v4.16.1` via API. Hoy corre `v4.16.2`, asi que en
algun momento entre el 2026-07-24 y hoy alguien o algo movio el tag y hubo un
redeploy. **Antes de asumir que el pin protege, verificalo:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker ps --format '{{.Names}}|{{.Image}}' | grep chatwoot"
```
### 3. El guard fallo durante la ventana del update
Cuatro errores el 2026-08-07, los primeros dos en la ventana del revert diario:
```
[2026-08-07T16:17:15Z] ERROR: no se pudo leer INSTALLATION_PRICING_PLAN (stack caido o contenedor renombrado?)
[2026-08-07T16:20:03Z] ERROR: no se pudo leer INSTALLATION_PRICING_PLAN (stack caido o contenedor renombrado?)
[2026-08-07T23:11:45Z] ERROR: ...
[2026-08-07T23:15:02Z] ERROR: ...
```
**Estado tras la auditoria: sano.** Una corrida manual con `bash -x` lee el plan
sin problema y sale 0, y el valor en la DB es `enterprise`:
```
INSTALLATION_PRICING_PLAN|"--- !ruby/hash:...\nvalue: enterprise\n"
```
Es decir: los errores fueron transitorios, coincidentes con la recreacion de
contenedores del update a `v4.16.2`. **No hay accion urgente.** Pero deja
expuesto un riesgo estructural.
### 4. Riesgo estructural: el guard tiene el nombre del contenedor hardcodeado
`scripts/chatwoot-enterprise-guard.sh` fija:
```bash
UUID=c11xzy2tx2cdapm32f5b89vy
DB_CT="postgres-$UUID"
APP_CT="chatwoot-$UUID"
```
Mientras el uuid del *service* no cambie, los nombres se mantienen — y en este
caso se mantuvieron. Pero el propio mensaje de error del guard nombra la causa
("contenedor renombrado?"), y **un redeploy que cambie el sufijo lo deja ciego
sin avisar**: el guard sale con codigo 1 y solo escribe una linea en un log que
nadie lee. El revert diario dejaria de repararse en silencio.
Mitigacion pendiente (no aplicada — requiere confirmacion porque toca el host):
hacer que el guard **resuelva el nombre por patron** en vez de fijarlo, p. ej.
`docker ps --format '{{.Names}}' | grep -m1 '^postgres-'` acotado al uuid del
service consultado a la API de Coolify. Y que N fallos consecutivos escalen a
algo visible, no solo al log.
### Comprobacion rapida del estado
```powershell
. .\.env.local.ps1
.\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "tail -20 /var/log/chatwoot-enterprise-guard.log"
```
Nota sobre el quoting: cualquier lectura directa de la DB necesita el patron
base64, porque `Invoke-ProxmoxSsh.ps1` corrompe las comillas anidadas. Ver
[../TOOL-INDEX.md](../TOOL-INDEX.md) §1.2.
+5 -5
View File
@@ -20,9 +20,9 @@ Síntomas típicos que llevan aquí:
- Dominio de app con `502 Bad Gateway` o `530`
Documentos relacionados:
[ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md](../ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md) ·
[cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md) ·
[issue-coolify-static-app-deploy.md](../issue-coolify-static-app-deploy.md)
[2026-04-11-cloudflare-tunnel-websocket-tls.md](../incidentes/2026-04-11-cloudflare-tunnel-websocket-tls.md) ·
[guía de agente 2026-04 (OBSOLETA)](../incidentes/2026-04-cloudflare-tunnel-guia-agente-OBSOLETA.md) ·
[2026-04-11-coolify-static-app-deploy.md](../incidentes/2026-04-11-coolify-static-app-deploy.md)
---
@@ -162,7 +162,7 @@ $body = @{
> ⚠️ El último elemento `http_status:404` (sin hostname) es obligatorio o la API
> rechaza la configuración. La referencia completa de endpoints (DNS, crear túnel,
> obtener token) está en [cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md).
> obtener token) está en [guía de agente 2026-04 (OBSOLETA)](../incidentes/2026-04-cloudflare-tunnel-guia-agente-OBSOLETA.md).
**Cualquier `PUT`/`POST` a Cloudflare es un cambio de estado → confirmar con el usuario y
capturar el estado actual (paso 2.2) antes de aplicar, para tener rollback.**
@@ -187,7 +187,7 @@ terminal en cualquier contenedor. Debe conectar sin `Terminal websocket connecti
cambio son **residuales** de conexiones ya abiertas; desaparecen solos.
- Si el túnel se cae cada ~5 min con `failed to dial to edge with quic: timeout`,
el contenedor debe correr con `--protocol http2` (UDP/7844 suele estar bloqueado
en redes domésticas). Ver PASO 6 de [cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md).
en redes domésticas). Ver PASO 6 de [guía de agente 2026-04 (OBSOLETA)](../incidentes/2026-04-cloudflare-tunnel-guia-agente-OBSOLETA.md).
- `cloudflared` es distroless: para leer archivos internos usa `docker cp`, no `cat` directo.
---
+1 -1
View File
@@ -8,7 +8,7 @@ Usa un archivo privado `.env.local.ps1` con estos valores:
$env:PROXMOX_HOST = "192.168.0.200"
$env:PROXMOX_NODE = "thinkcentre"
$env:PROXMOX_USER = "root"
$env:PROXMOX_SSH_KEY = "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win"
$env:PROXMOX_SSH_KEY = "keys\proxmox_ed25519"
$env:PROXMOX_API_BASE_URL = "https://192.168.0.200:8006/api2/json"
$env:PROXMOX_API_TOKEN_ID = "root@pam!openclaw"
$env:PROXMOX_API_TOKEN_SECRET = "REPLACE_WITH_TOKEN_SECRET"
+1 -1
View File
@@ -36,6 +36,6 @@ Requiere confirmacion explicita.
Usar solo cuando haga falta inspeccion manual.
```powershell
ssh -i "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win" root@192.168.0.200
ssh -i "keys\proxmox_ed25519" root@192.168.0.200
pct exec 102 -- docker exec -it <container> /bin/sh
```
+1 -1
View File
@@ -23,7 +23,7 @@ Si el endpoint API de Coolify no esta disponible o no hay token cargado:
No usar `sed` para editar `config.php`. Usar el script PHP del repo:
```powershell
scp -o StrictHostKeyChecking=no -i "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win" .\scripts\fix-nextcloud-config.php root@192.168.0.200:/tmp/fix-nextcloud-config.php
scp -o StrictHostKeyChecking=no -i "keys\proxmox_ed25519" .\scripts\fix-nextcloud-config.php root@192.168.0.200:/tmp/fix-nextcloud-config.php
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct push 102 /tmp/fix-nextcloud-config.php /tmp/fix-nextcloud-config.php"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker cp /tmp/fix-nextcloud-config.php nextcloud-hdcdpkm0jko3qqvn5683ercc:/tmp/fix-nextcloud-config.php"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec nextcloud-hdcdpkm0jko3qqvn5683ercc php /tmp/fix-nextcloud-config.php /config/www/nextcloud/config/config.php nextcloudsuite.urieljareth.org nextcloud-db"