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
+134 -5
View File
@@ -8,18 +8,28 @@
# Criterio de exito: psql imprime 3 lineas "UPDATE 1" (una por sentencia).
# Tras aplicar, NO pulsar "Refresh" en /super_admin/settings.
#
# IMPORTANTE: los 3 UPDATE por si solos NO alcanzan cuando el plan ya se habia
# revertido a 'community'. Al revertirse, Internal::ReconcilePlanConfigService
# apaga los 9 feature flags premium en CADA cuenta, y eso vive en la tabla
# accounts (bitmask), no en installation_configs. Usa -ReenableAccountFeatures
# para reactivarlos. Ver docs/runbooks/chatwoot-update.md.
#
# Uso:
# .\scripts\Apply-ChatwootEnterprisePatch.ps1
# .\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
# .\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures
# .\scripts\Apply-ChatwootEnterprisePatch.ps1 -Container "postgres-c11xzy2tx2cdapm32f5b89vy"
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$ReenableAccountFeatures,
[string]$ServiceUuid = "c11xzy2tx2cdapm32f5b89vy",
[string]$Container = "",
[string]$AppContainer = "",
[string]$LxcId = "102",
[string]$ProxmoxHost = $(if ($env:PROXMOX_HOST) { $env:PROXMOX_HOST } else { "192.168.0.200" }),
[string]$SshKey = $(if ($env:PROXMOX_SSH_KEY) { $env:PROXMOX_SSH_KEY } else { "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win" })
[string]$SshKey = $(if ($env:PROXMOX_SSH_KEY) { $env:PROXMOX_SSH_KEY } else { Join-Path $PSScriptRoot "..\keys\proxmox_ed25519" })
)
# Encoding UTF-8 sin BOM (BOM rompe el shebang #!/bin/bash en Linux).
@@ -44,13 +54,18 @@ function Invoke-Remote {
return & ssh @args
}
if (-not $AppContainer) { $AppContainer = "chatwoot-$ServiceUuid" }
# --- 1) Resolver contenedor Postgres de Chatwoot --------------------------
if (-not $Container) {
# Dos greps encadenados en vez de un solo patron: Coolify nombra los
# contenedores <servicio>-<uuid> (postgres-c11xzy...), asi que un patron
# "<uuid>.*postgres" nunca casa. El orden no importa con greps separados.
$detect = @'
#!/bin/bash
pct exec __LXC__ -- bash -lc 'docker ps --format "{{.Names}}" | grep -Ei "c11xzy2tx2cdapm32f5b89vy.*(pgvector|postgres|db)" | head -n1'
pct exec __LXC__ -- bash -lc 'docker ps --format "{{.Names}}" | grep -F "__UUID__" | grep -Ei "(pgvector|postgres|db)" | head -n1'
'@
$detect = $detect.Replace('__LXC__', $LxcId)
$detect = $detect.Replace('__LXC__', $LxcId).Replace('__UUID__', $ServiceUuid)
$tmpDetect = [IO.Path]::GetTempFileName() + ".sh"
[IO.File]::WriteAllText($tmpDetect, $detect, $utf8NoBom)
@@ -72,8 +87,9 @@ pct exec __LXC__ -- bash -lc 'docker ps --format "{{.Names}}" | grep -Ei "c11xzy
if ($LASTEXITCODE -ne 0) { throw "Listado remoto fallo (exit $LASTEXITCODE)." }
$Container = @($cand | Where-Object { $_ -match '\S' })[0]
if (-not $Container) {
throw "No se encontro contenedor. Pasa -Container explicito (ej: postgres-c11xzy2tx2cdapm32f5b89vy)."
throw "No se encontro contenedor Postgres para el servicio $ServiceUuid en el LXC $LxcId. Pasa -Container explicito (ej: postgres-$ServiceUuid)."
}
$Container = $Container.Trim()
} finally {
Remove-Item -LiteralPath $tmpDetect -ErrorAction SilentlyContinue
}
@@ -174,8 +190,23 @@ if ($DryRun) {
Write-Host "------"
Write-Host "[dry-run] apply.sh:"
Write-Host "------"
Write-Host $applyScript
# PGPASSWORD se enmascara: la regla del repo es que ningun secreto salga por
# stdout ni quede en un log. (String.Replace revienta con un patron vacio,
# de ahi el guard.)
$safeApply = if ([string]::IsNullOrEmpty($pgPass)) { $applyScript } else { $applyScript.Replace($pgPass, "********") }
Write-Host $safeApply
Write-Host "------"
if ($ReenableAccountFeatures) {
Write-Host "[dry-run] Ademas reactivaria estos feature flags premium en TODAS las cuentas,"
Write-Host " via 'rails runner' en $AppContainer :"
Write-Host " disable_branding audit_logs sla custom_roles captain_integration"
Write-Host " captain_integration_v2 captain_document_auto_sync csat_review_notes"
Write-Host " conversation_required_attributes"
}
else {
Write-Host "[dry-run] Los feature flags premium por cuenta NO se tocarian."
Write-Host " Agrega -ReenableAccountFeatures si el plan venia de 'community'."
}
return
}
@@ -266,3 +297,101 @@ Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null
Write-Host ""
Write-Host "[OK] Parche enterprise aplicado correctamente (3/3 UPDATE 1)."
Write-Host " NO pulsar 'Refresh' en /super_admin/settings."
# --- 6) Reactivar feature flags premium por cuenta -------------------------
# Cuando el plan se revierte a 'community', Internal::ReconcilePlanConfigService
# corre account.disable_features!(*premium_features) sobre TODAS las cuentas.
# Esos flags viven en accounts.feature_flags (bitmask) y los 3 UPDATE de arriba
# no los tocan: hay que reactivarlos explicitamente o la UI sigue sin enterprise.
if (-not $ReenableAccountFeatures) {
Write-Host ""
Write-Host "[!] Los feature flags premium por cuenta NO se tocaron."
Write-Host " Si el plan venia de 'community', vuelve a correr con -ReenableAccountFeatures."
return
}
Write-Host ""
Write-Host "[*] Reactivando feature flags premium por cuenta (arranca Rails, ~40 s) ..."
$ruby = @'
PREMIUM = %w[
disable_branding audit_logs sla custom_roles
captain_integration captain_integration_v2 captain_document_auto_sync
csat_review_notes conversation_required_attributes
]
# Los 3 UPDATE se hacen por SQL puro, asi que NO disparan el
# `after_commit :clear_cache` de InstallationConfig. GlobalConfig cachea en Redis
# con TTL de 1 dia (V1:GLOBAL_CONFIG:*), asi que sin esta limpieza la app puede
# seguir sirviendo el plan viejo hasta 24 h.
GlobalConfig.clear_cache
puts "global_config_cache=limpiado"
Account.find_each do |account|
before = PREMIUM.reject { |f| account.feature_enabled?(f) }
account.enable_features!(*PREMIUM)
account.reload
after = PREMIUM.reject { |f| account.feature_enabled?(f) }
puts "account=#{account.id}|#{account.name}|reactivados=#{before.empty? ? 'ninguno' : before.join(',')}|pendientes=#{after.empty? ? 'ninguno' : after.join(',')}"
end
puts "self_hosted_enterprise=#{ChatwootApp.self_hosted_enterprise?}"
'@
$tmpRb = [IO.Path]::GetTempFileName()
$remoteRb = "/tmp/chatwoot-reenable-features.rb"
$tmpRunner = [IO.Path]::GetTempFileName()
$remoteRunner = "/tmp/chatwoot-reenable-features.sh"
$runner = @'
set -e
pct push __LXC__ __RB__ __RB__
pct exec __LXC__ -- docker cp __RB__ __APP__:__RB__
pct exec __LXC__ -- docker exec -i __APP__ bundle exec rails runner __RB__
pct exec __LXC__ -- docker exec -i __APP__ rm -f __RB__
pct exec __LXC__ -- rm -f __RB__
'@
$runner = $runner.Replace('__LXC__', $LxcId).Replace('__APP__', $AppContainer).Replace('__RB__', $remoteRb)
try {
[IO.File]::WriteAllText($tmpRb, $ruby, $utf8NoBom)
[IO.File]::WriteAllText($tmpRunner, $runner, $utf8NoBom)
foreach ($pair in @(@($tmpRb, $remoteRb), @($tmpRunner, $remoteRunner))) {
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$pair[0]
"root@${ProxmoxHost}:$($pair[1])"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp de $($pair[1]) fallo (exit $LASTEXITCODE)." }
}
$featOut = Invoke-Remote "bash $remoteRunner"
$featExit = $LASTEXITCODE
$featOut | ForEach-Object { Write-Host $_ }
}
finally {
Remove-Item -LiteralPath $tmpRb, $tmpRunner -ErrorAction SilentlyContinue
Invoke-Remote "rm -f $remoteRb $remoteRunner" | Out-Null
}
if ($featExit -ne 0) {
throw "La reactivacion de feature flags fallo (exit $featExit)."
}
$pending = @($featOut | Where-Object { $_ -match 'pendientes=(?!ninguno)' })
if ($pending.Count -gt 0) {
throw "Quedaron feature flags premium sin activar en $($pending.Count) cuenta(s). Revisa la salida de arriba."
}
$selfHosted = @($featOut | Where-Object { $_ -like "self_hosted_enterprise=*" })[0]
Write-Host ""
if ($selfHosted -eq "self_hosted_enterprise=true") {
Write-Host "[OK] Enterprise activo: plan=enterprise y feature flags premium reactivados en todas las cuentas."
}
else {
Write-Host "[WARN] Feature flags reactivados, pero ChatwootApp.self_hosted_enterprise? no dio true ($selfHosted)."
Write-Host " Reinicia el stack para limpiar el cache de GlobalConfig y vuelve a verificar con:"
Write-Host " .\scripts\Get-ChatwootLicenseStatus.ps1 -Deep"
}
+250
View File
@@ -0,0 +1,250 @@
# Reporta el estado de la licencia enterprise de Chatwoot en Coolify (LXC 102).
#
# Solo lectura. Pensado como pre-check y post-check del runbook de actualizacion
# (docs/runbooks/chatwoot-update.md).
#
# Que reporta:
# - version instalada vs ultima conocida por el hub
# - las 3 filas de public.installation_configs del parche enterprise
# - con -Deep: ChatwootApp.self_hosted_enterprise? y los 9 feature flags
# premium por cuenta (esto tarda ~40 s porque arranca Rails)
#
# Uso:
# .\scripts\Get-ChatwootLicenseStatus.ps1
# .\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
[CmdletBinding()]
param(
[string]$ServiceUuid = "c11xzy2tx2cdapm32f5b89vy",
[string]$AppContainer = "",
[string]$DbContainer = "",
[switch]$Deep
)
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "ProxmoxAgent.ps1")
$config = Assert-ProxmoxConfig
$lxc = $config.CoolifyLxc
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Feature flags premium que Internal::ReconcilePlanConfigService apaga cuando el
# plan vuelve a 'community' (enterprise/config/premium_features.yml).
$premiumFeatures = @(
"disable_branding", "audit_logs", "sla", "custom_roles",
"captain_integration", "captain_integration_v2", "captain_document_auto_sync",
"csat_review_notes", "conversation_required_attributes"
)
function Invoke-Remote {
param([string]$RemoteCmd)
$sshArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=20"
"-o", "StrictHostKeyChecking=no"
"-i", $config.SshKey
"$($config.User)@$($config.HostName)"
$RemoteCmd
)
return & ssh @sshArgs
}
# Sube un script como archivo y lo ejecuta con bash. Evita el infierno de
# escaping de comillas sobre SSH (ver docs/casos/chatwoot-enterprise-patch.md).
function Invoke-RemoteScript {
param(
[string]$Body,
[string]$RemoteName
)
$tmp = [IO.Path]::GetTempFileName()
try {
[IO.File]::WriteAllText($tmp, $Body, $utf8NoBom)
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=20"
"-o", "StrictHostKeyChecking=no"
"-i", $config.SshKey
$tmp
"$($config.User)@$($config.HostName):/tmp/$RemoteName"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp de $RemoteName fallo (exit $LASTEXITCODE)." }
$out = Invoke-Remote "bash /tmp/$RemoteName"
$code = $LASTEXITCODE
Invoke-Remote "rm -f /tmp/$RemoteName" | Out-Null
if ($code -ne 0) { throw "$RemoteName fallo en el host (exit $code)." }
return $out
}
finally {
Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue
}
}
# --- 1) Resolver contenedores del stack -----------------------------------
if (-not $AppContainer) { $AppContainer = "chatwoot-$ServiceUuid" }
if (-not $DbContainer) { $DbContainer = "postgres-$ServiceUuid" }
$psScript = @'
pct exec __LXC__ -- docker ps --format "{{.Names}} {{.Status}}" | grep -E "__UUID__"
'@
$psScript = $psScript.Replace('__LXC__', $lxc).Replace('__UUID__', $ServiceUuid)
$containers = Invoke-RemoteScript -Body $psScript -RemoteName "cw-status-ps.sh"
Write-Host "=== Stack Chatwoot (LXC $lxc) ==="
if (-not $containers) {
throw "No se encontro ningun contenedor con el UUID $ServiceUuid en el LXC $lxc."
}
$containers | ForEach-Object { Write-Host " $_" }
# --- 2) Version instalada -------------------------------------------------
$verScript = @'
pct exec __LXC__ -- docker exec -i __APP__ sh -c 'grep -m1 "version:" /app/config/app.yml'
'@
$verScript = $verScript.Replace('__LXC__', $lxc).Replace('__APP__', $AppContainer)
$verRaw = (Invoke-RemoteScript -Body $verScript -RemoteName "cw-status-ver.sh") -join " "
$version = if ($verRaw -match "'([^']+)'") { $Matches[1] } else { $verRaw.Trim() }
Write-Host ""
Write-Host "=== Version ==="
Write-Host " instalada: $version"
# --- 3) Filas del parche enterprise ---------------------------------------
# La query se ejecuta dentro del contenedor Postgres para que POSTGRES_USER /
# POSTGRES_PASSWORD nunca salgan del contenedor ni queden en logs.
$sqlScript = @'
pct exec __LXC__ -- docker exec -i __DB__ bash -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -F "|" -c "SELECT name, serialized_value FROM public.installation_configs ORDER BY name"'
'@
$sqlScript = $sqlScript.Replace('__LXC__', $lxc).Replace('__DB__', $DbContainer)
$rows = Invoke-RemoteScript -Body $sqlScript -RemoteName "cw-status-sql.sh"
function Get-ConfigValue {
param([string]$Name)
$line = @($rows | Where-Object { $_ -like "$Name|*" })[0]
if (-not $line) { return "<ausente>" }
# serialized_value es YAML de Ruby: "--- ...\nvalue: X\n"
if ($line -match 'value:\s*([^\\"]*)') { return $Matches[1].Trim() }
return $line
}
$plan = Get-ConfigValue "INSTALLATION_PRICING_PLAN"
$quantity = Get-ConfigValue "INSTALLATION_PRICING_PLAN_QUANTITY"
$identifier = Get-ConfigValue "INSTALLATION_IDENTIFIER"
$instName = Get-ConfigValue "INSTALLATION_NAME"
Write-Host ""
Write-Host "=== installation_configs (parche enterprise) ==="
Write-Host " INSTALLATION_PRICING_PLAN = $plan"
Write-Host " INSTALLATION_PRICING_PLAN_QUANTITY = $quantity"
Write-Host " INSTALLATION_IDENTIFIER = $identifier"
Write-Host " INSTALLATION_NAME = $instName"
# La ventana diaria de revert es determinista:
# Internal::TriggerDailyScheduledItemsJob (cron 0 0 * * *) programa
# Internal::CheckNewVersionsJob en beginning_of_day + (MD5(identifier).hex % 1440) minutos.
if ($identifier -and $identifier -ne "<ausente>") {
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$digest = ($md5.ComputeHash([Text.Encoding]::UTF8.GetBytes($identifier)) |
ForEach-Object { $_.ToString("x2") }) -join ""
$asInt = [Numerics.BigInteger]::Parse("0$digest", "AllowHexSpecifier")
$minute = [int]($asInt % 1440)
Write-Host (" ventana diaria de revert = {0:d2}:{1:d2} UTC (minuto {2})" -f [int][math]::Floor($minute / 60), [int]($minute % 60), $minute)
}
finally {
$md5.Dispose()
}
}
$planOk = ($plan -eq "enterprise")
# --- 4) Chequeo profundo con Rails ----------------------------------------
$featuresOk = $null
if ($Deep) {
Write-Host ""
Write-Host "[*] Arrancando Rails para el chequeo profundo (~40 s) ..."
$ruby = @'
PREMIUM = %w[__FEATURES__]
puts "version=#{Chatwoot.config[:version]}"
puts "enterprise=#{ChatwootApp.enterprise?}"
puts "self_hosted_enterprise=#{ChatwootApp.self_hosted_enterprise?}"
puts "latest_known_version=#{Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION)}"
Account.find_each do |a|
off = PREMIUM.reject { |f| a.feature_enabled?(f) }
puts "account=#{a.id}|#{a.name}|#{off.empty? ? 'OK' : off.join(',')}"
end
'@
$ruby = $ruby.Replace('__FEATURES__', ($premiumFeatures -join " "))
$tmpRb = [IO.Path]::GetTempFileName()
try {
[IO.File]::WriteAllText($tmpRb, $ruby, $utf8NoBom)
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=20"
"-o", "StrictHostKeyChecking=no"
"-i", $config.SshKey
$tmpRb
"$($config.User)@$($config.HostName):/tmp/cw-deep.rb"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp del script Ruby fallo (exit $LASTEXITCODE)." }
# El .rb tiene que llegar al filesystem del contenedor: host -> LXC -> docker.
$runner = @'
set -e
pct push __LXC__ /tmp/cw-deep.rb /tmp/cw-deep.rb
pct exec __LXC__ -- docker cp /tmp/cw-deep.rb __APP__:/tmp/cw-deep.rb
pct exec __LXC__ -- docker exec -i __APP__ bundle exec rails runner /tmp/cw-deep.rb
pct exec __LXC__ -- docker exec -i __APP__ rm -f /tmp/cw-deep.rb
pct exec __LXC__ -- rm -f /tmp/cw-deep.rb
'@
$runner = $runner.Replace('__LXC__', $lxc).Replace('__APP__', $AppContainer)
$deepOut = Invoke-RemoteScript -Body $runner -RemoteName "cw-status-deep.sh"
Invoke-Remote "rm -f /tmp/cw-deep.rb" | Out-Null
}
finally {
Remove-Item -LiteralPath $tmpRb -ErrorAction SilentlyContinue
}
$selfHosted = @($deepOut | Where-Object { $_ -like "self_hosted_enterprise=*" })[0]
$latest = @($deepOut | Where-Object { $_ -like "latest_known_version=*" })[0]
$accounts = @($deepOut | Where-Object { $_ -like "account=*" })
Write-Host ""
Write-Host "=== Rails ==="
if ($latest) { Write-Host " $latest" }
if ($selfHosted) { Write-Host " $selfHosted" }
Write-Host ""
Write-Host "=== Feature flags premium por cuenta ==="
$featuresOk = $true
foreach ($line in $accounts) {
$parts = $line.Substring(8) -split '\|', 3
$state = if ($parts.Count -ge 3) { $parts[2] } else { "?" }
if ($state -ne "OK") { $featuresOk = $false }
Write-Host (" cuenta {0} ({1}): {2}" -f $parts[0], $parts[1], $(if ($state -eq "OK") { "todos activos" } else { "APAGADOS -> $state" }))
}
}
# --- 5) Veredicto ---------------------------------------------------------
Write-Host ""
if ($planOk -and ($featuresOk -eq $true)) {
Write-Host "[OK] Enterprise activo: plan=enterprise y feature flags premium completos."
}
elseif ($planOk -and ($null -eq $featuresOk)) {
Write-Host "[OK] plan=enterprise. Corre con -Deep para confirmar los feature flags por cuenta."
}
elseif ($planOk) {
Write-Host "[WARN] plan=enterprise pero hay feature flags premium apagados."
Write-Host " Corre: .\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures"
}
else {
Write-Host "[FAIL] Enterprise NO activo (plan=$plan, quantity=$quantity)."
Write-Host " Corre: .\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures"
Write-Host " Detalle del caso: docs/runbooks/chatwoot-update.md"
}
+9 -1
View File
@@ -96,12 +96,20 @@ if ($VerifyOnly) {
echo '--- onboot / startup on LXC $lxc ---'
grep -Ei 'onboot|startup' /etc/pve/lxc/$lxc.conf 2>/dev/null || echo '(onboot not set -> defaults to 0, will NOT auto-start)'
echo '--- guardian unit ---'
systemctl is-enabled $svcName 2>/dev/null || echo '($svcName not installed/enabled)'
printf 'enabled: '; systemctl is-enabled $svcName 2>/dev/null || echo '(not installed/enabled)'
printf 'state: '; systemctl is-active $svcName 2>/dev/null || true
printf 'result: '; systemctl show $svcName -p Result --value 2>/dev/null || true
printf 'timeout: '; systemctl show $svcName -p TimeoutStartUSec --value 2>/dev/null || true
echo '--- last boot outcome (journal) ---'
journalctl -u $svcName --no-pager -b 2>/dev/null | tail -n 6 || echo '(no journal for this boot)'
echo '--- guardian script present? ---'
test -x $binPath && echo 'present ($binPath)' || echo 'missing ($binPath)'
echo '--- last log lines ---'
tail -n 15 $logPath 2>/dev/null || echo '(no log yet)'
"@
Write-Host ""
Write-Host "Healthy = enabled:enabled / state:active / result:success and a log run ending in 'done'." -ForegroundColor DarkGray
Write-Host "state:failed or a log run that stops after 'LXC ... running' means the guardian died mid-wait." -ForegroundColor DarkGray
return
}
+15
View File
@@ -0,0 +1,15 @@
param(
[Parameter(Mandatory = $true)]
[string]$BashCommand
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\ProxmoxAgent.ps1"
$text = $BashCommand -replace "`r`n", "`n"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$b64 = [Convert]::ToBase64String($bytes)
# On Proxmox host, run pct exec with base64 decoded directly inside container
$remoteCmd = "pct exec 102 -- bash -c 'echo " + $b64 + " | base64 -d | bash'"
Invoke-ProxmoxSshCommand -Command $remoteCmd
+1 -1
View File
@@ -10,7 +10,7 @@ function Get-ProxmoxConfig {
HostName = if ($env:PROXMOX_HOST) { $env:PROXMOX_HOST } else { "192.168.0.200" }
Node = if ($env:PROXMOX_NODE) { $env:PROXMOX_NODE } else { "thinkcentre" }
User = if ($env:PROXMOX_USER) { $env:PROXMOX_USER } else { "root" }
SshKey = if ($env:PROXMOX_SSH_KEY) { $env:PROXMOX_SSH_KEY } else { "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win" }
SshKey = if ($env:PROXMOX_SSH_KEY) { $env:PROXMOX_SSH_KEY } else { Join-Path $PSScriptRoot "..\keys\proxmox_ed25519" }
ApiBaseUrl = if ($env:PROXMOX_API_BASE_URL) { $env:PROXMOX_API_BASE_URL } else { "https://192.168.0.200:8006/api2/json" }
ApiTokenHeader = $tokenHeader
CoolifyLxc = if ($env:PROXMOX_COOLIFY_LXC) { $env:PROXMOX_COOLIFY_LXC } else { "102" }
+1 -1
View File
@@ -3,7 +3,7 @@
$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" # relativo a la raíz del repo (≡ ~\.ssh\coolify_key)
$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"
+102
View File
@@ -0,0 +1,102 @@
# ===========================================================================
# Deploy-OhDaddy.ps1
# Redeploy de oh-daddy (https://github.com/KenKaiii/oh-daddy) en Coolify
# (LXC 102) SIN depender del pull de Coolify (la imagen es local:
# oh-daddy-app:local, construida en el server - patron Deploy-SoloLeveling).
#
# Stack: app (Next.js 16) + db (postgres:17) + inngest (self-hosted v1.44.0)
# + inngest-db + inngest-redis. Servicio Coolify rzittzudkunwx8gilonn7tqe,
# proyecto "AI AGENCY" / production. Dominio: ohdaddy.urieljareth.org.
#
# Pre-requisitos:
# - .env.local.ps1 con COOLIFY_*, PROXMOX_* (los secretos OH_DADDY_* solo
# se necesitan si vas a re-aplicar envs; el deploy los lee del .env del
# servicio en disco).
# - stacks/oh-daddy/{Dockerfile,.dockerignore} en este repo (se inyectan
# al clone via base64).
#
# Uso:
# . .\.env.local.ps1
# .\scripts\apps\Deploy-OhDaddy.ps1 # build + up + schema + registro
# .\scripts\apps\Deploy-OhDaddy.ps1 -NoBuild # solo up + verificaciones
# ===========================================================================
[CmdletBinding()]
param(
[string]$ServiceUuid = 'rzittzudkunwx8gilonn7tqe',
[string]$Fqdn = 'https://ohdaddy.urieljareth.org',
[string]$Repo = 'https://github.com/KenKaiii/oh-daddy.git',
[string]$Image = 'oh-daddy-app:local',
[switch]$NoBuild
)
Set-Location 'H:\MegaSync\Proyectos\Proxmox & Coolify Manager'
. .\.env.local.ps1
$ErrorActionPreference = 'Stop'
function Invoke-OnServer([string]$scriptBody, [int]$TimeoutSec = 600) {
$body = ($scriptBody -replace "`r`n","`n") -replace "`r","`n"
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body))
$cmd = "pct exec 102 -- sh -c 'echo $b64 | base64 -d | sh'"
& .\scripts\Invoke-ProxmoxSsh.ps1 -Command $cmd
}
$stackDir = 'H:\MegaSync\Proyectos\Proxmox & Coolify Manager\stacks\oh-daddy'
# ---------------------------------------------------------------- 1. BUILD ---
if (-not $NoBuild) {
Write-Host "==> Construyendo imagen en el server (clone + docker build)..." -ForegroundColor Cyan
$dfB64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $stackDir 'Dockerfile')))
$diB64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $stackDir '.dockerignore')))
$build = @"
set -e
BUILD=/tmp/oh-daddy-build
rm -rf "`$BUILD" /tmp/oh-daddy-build.log
mkdir -p "`$BUILD"; cd "`$BUILD"
git clone --depth 1 $Repo repo
cd repo
echo "HEAD: `$(git log -1 --oneline)"
echo '$dfB64' | base64 -d > Dockerfile
echo '$diB64' | base64 -d > .dockerignore
nohup sh -c 'cd /tmp/oh-daddy-build/repo && docker build --build-arg NEXT_PUBLIC_APP_URL=$Fqdn -t $Image . ; echo BUILD_EXIT=`$?' > /tmp/oh-daddy-build.log 2>&1 &
echo BUILD_STARTED
"@
Invoke-OnServer $build
Write-Host " (build en background; log: pct exec 102 -- tail -f /tmp/oh-daddy-build.log)" -ForegroundColor DarkGray
Write-Host "==> Esperando build (poll cada 30s, max 15min)..." -ForegroundColor Cyan
$deadline = (Get-Date).AddMinutes(15)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 30
$st = Invoke-OnServer "set +e`ntail -3 /tmp/oh-daddy-build.log`ndocker images $Image --format '{{.ID}}'"
if ($st -match 'BUILD_EXIT=0') { Write-Host "OK: imagen $Image construida." -ForegroundColor Green; break }
if ($st -match 'BUILD_EXIT=([1-9][0-9]*)') { throw "Build fallo (exit $($Matches[1])). Log: /tmp/oh-daddy-build.log" }
Write-Host " ... compilando" -ForegroundColor DarkGray
}
if (-not $st) { throw "No se pudo confirmar el build." }
}
# -------------------------------------------------- 2. UP + PROXY + SCHEMA ---
Write-Host "==> Levantando stack, conectando proxy y aplicando schema..." -ForegroundColor Cyan
$up = @"
set -e
SVC=/data/coolify/services/$ServiceUuid
cd "`$SVC"
docker compose up -d
docker network connect $ServiceUuid coolify-proxy 2>&1 && echo PROXY_CONNECTED || echo "(proxy ya conectado)"
echo "=== SCHEMA (idempotente) ==="
docker exec -i db-$ServiceUuid psql -U ohdaddy -d ohdaddy < /tmp/oh-daddy-build/repo/db/schema.sql 2>&1 | grep -cE 'ERROR' | sed 's/^/errores_sql=/' || true
echo "=== PS ==="
sleep 15
docker compose ps --format '{{.Name}} | {{.Status}}'
"@
Invoke-OnServer $up
# ------------------------------------------- 3. REGISTRO INNGEST + SALUD ---
Write-Host "==> Re-registrando funciones en Inngest (PUT publico)..." -ForegroundColor Cyan
$code = & curl.exe -s -o NUL -w "%{http_code}" --max-time 60 -X PUT "$Fqdn/api/inngest"
Write-Host ("INNGEST_REGISTER=" + $code)
if ($code -ne '200') { Write-Host "AVISO: registro no confirmado. Reintentar cuando la app este healthy: curl -X PUT $Fqdn/api/inngest" -ForegroundColor Yellow }
Write-Host "=== HEALTH publico: $Fqdn ===" -ForegroundColor Cyan
$code2 = & curl.exe -s -o NUL -w "%{http_code}" --max-time 30 "$Fqdn/login"
Write-Host ("PUBLIC_LOGIN=" + $code2)
if ($code2 -eq '200') { Write-Host "OK: oh-daddy activo en $Fqdn" -ForegroundColor Green }
else { Write-Host "AVISO: /login no responde 200 ($code2). Logs: docker logs app-$ServiceUuid" -ForegroundColor Yellow }
+87
View File
@@ -0,0 +1,87 @@
# ===========================================================================
# Deploy-SoloLeveling.ps1
# Re-deploy de "El Sistema (Solo Leveling)" en Coolify (LXC 102) SIN depender
# del pull de GHCR (que es privado y sin token read:packages).
#
# Estrategia: construye la imagen DIRECTO en el server (clone del repo privado
# con el PAT scope=repo + docker build), la taguea como ghcr.io/.../solo-leveling:latest
# (el nombre que espera el compose generado por Coolify) y levanta el servicio.
# Como el compose NO declara pull_policy, docker compose up usa la imagen local.
#
# Pre-requisitos:
# - .env.local.ps1 con COOLIFY_*, PROXMOX_* y GITHUB_TOKEN (scope repo).
# - El service ya registrado en Coolify (compose + .env + dominio en su dir).
#
# Uso:
# . .\.env.local.ps1
# .\Deploy-SoloLeveling.ps1 # build + up + verificar
# .\Deploy-SoloLeveling.ps1 -NoBuild # solo up + verificar (imagen ya existe)
# ===========================================================================
[CmdletBinding()]
param(
[string]$ServiceUuid = 'urm8m4u0jvjggmgpfxblnqwc',
[string]$Domain = 'sl.urieljareth.org',
[string]$Repo = 'urieljarethbusiness-cpu/solo-leveling',
[string]$Image = 'ghcr.io/urieljarethbusiness-cpu/solo-leveling:latest',
[switch]$NoBuild
)
Set-Location 'H:\MegaSync\Proyectos\Proxmox & Coolify Manager'
. .\.env.local.ps1
$ErrorActionPreference = 'Stop'
if (-not $env:GITHUB_TOKEN) { throw "GITHUB_TOKEN falta en .env.local.ps1" }
# --- helper: ejecuta un .sh (string) dentro del LXC 102 via base64 (sin quote hell) ---
function Invoke-OnServer([string]$scriptBody, [int]$TimeoutSec = 600) {
$body = ($scriptBody -replace "`r`n","`n") -replace "`r","`n"
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body))
$cmd = "pct exec 102 -- sh -c 'echo $b64 | base64 -d | sh'"
& .\scripts\Invoke-ProxmoxSsh.ps1 -Command $cmd
}
# ---------------------------------------------------------------- 1. BUILD ---
if (-not $NoBuild) {
Write-Host "==> Construyendo imagen en el server (clone + docker build)..." -ForegroundColor Cyan
$build = @"
set -e
BUILD_DIR=/tmp/solo-build
rm -rf `"`$BUILD_DIR`" `"/tmp/solo-build.log`"
mkdir -p `"`$BUILD_DIR`"; cd `"`$BUILD_DIR`"
git clone --depth 1 https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$Repo.git repo
cd repo
echo "HEAD: `$(git log -1 --oneline)"
docker build -t $Image .
echo "=== BUILT ==="
docker images $Image --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'
"@
Invoke-OnServer $build
}
# --------------------------------------------- 2. UP + CONECTAR PROXY ---
Write-Host "==> Levantando servicio y conectando proxy Traefik..." -ForegroundColor Cyan
$up = @"
set +e
SVC=/data/coolify/services/$ServiceUuid
cd "`$SVC" || { echo "NO_SVC_DIR"; exit 1; }
docker compose up -d
docker network connect $ServiceUuid coolify-proxy 2>&1 && echo "PROXY_CONNECTED" || echo "(proxy ya conectado)"
"@
Invoke-OnServer $up
# -------------------------------------------------- 3. ESPERAR + VERIFICAR ---
Write-Host "==> Esperando salud (migrate/seed + arranque Next)..." -ForegroundColor Cyan
Start-Sleep -Seconds 25
$verify = @"
set +e
echo "=== STATUS ==="
docker compose -f /data/coolify/services/$ServiceUuid/docker-compose.yml ps --format '{{.Name}} | {{.Status}}'
echo "=== HEALTH (local) ==="
docker exec app-$ServiceUuid wget -qO- http://127.0.0.1:3000/api/health 2>&1
echo ""
"@
Invoke-OnServer $verify
Write-Host "=== HEALTH (publico: https://$Domain) ===" -ForegroundColor Cyan
$code = & curl.exe -s -o NUL -w "%{http_code}" --max-time 30 "https://$Domain/api/health"
Write-Host ("PUBLIC_HEALTH=" + $code)
if ($code -eq '200') { Write-Host "OK: sitio activo en https://$Domain" -ForegroundColor Green }
else { Write-Host "AVISO: health publico no es 200 ($code). Revisar logs: docker logs app-$ServiceUuid" -ForegroundColor Yellow }
+140
View File
@@ -0,0 +1,140 @@
#!/bin/bash
# Guard: mantiene la edicion enterprise de Chatwoot en LXC 102.
#
# Por que existe: Internal::CheckNewVersionsJob hace ping diario a
# hub.2.chatwoot.com (a las 16:16 UTC para este installation_identifier) y
# reescribe INSTALLATION_PRICING_PLAN con lo que responda el hub ('community'),
# y ademas Internal::ReconcilePlanConfigService apaga los 9 feature flags
# premium en TODAS las cuentas.
#
# No se bloquea el hub a proposito: ese mismo host relaya las notificaciones
# push del movil (ChatwootHub.send_push) porque FIREBASE_* esta vacio. Bloquearlo
# romperia el push. En vez de eso, este guard detecta el revert y lo deshace.
#
# Cheap por diseno: en el caso normal hace 1 SELECT y sale. Solo cuando detecta
# plan != enterprise levanta Rails para limpiar cache y reactivar flags.
#
# Instalado por el runbook docs/runbooks/chatwoot-update.md
# Cron: */15 * * * *
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LXC=102
UUID=c11xzy2tx2cdapm32f5b89vy
DB_CT="postgres-$UUID"
APP_CT="chatwoot-$UUID"
LOG=/var/log/chatwoot-enterprise-guard.log
LOCK=/var/lock/chatwoot-enterprise-guard.lock
MAX_LOG=2097152
log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*" >> "$LOG"; }
# Rotacion simple para que el log no crezca sin control.
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt "$MAX_LOG" ]; then
mv -f "$LOG" "$LOG.1"
fi
# Una sola instancia a la vez (el camino de reparacion tarda ~1 min).
exec 9>"$LOCK" || exit 0
flock -n 9 || exit 0
# --- 1) Chequeo barato: en que plan estamos ---------------------------------
# Se traen todas las filas y se filtra con grep a proposito: un WHERE con
# literales necesitaria comillas simples anidadas dentro de bash -lc '...' y eso
# es exactamente la clase de escaping que rompe estos scripts.
PLAN=$(pct exec "$LXC" -- docker exec -i "$DB_CT" bash -lc \
'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -F "|" -c "SELECT name, serialized_value::text FROM public.installation_configs"' \
2>/dev/null | grep '^INSTALLATION_PRICING_PLAN|')
if [ -z "$PLAN" ]; then
log "ERROR: no se pudo leer INSTALLATION_PRICING_PLAN (stack caido o contenedor renombrado?)"
exit 1
fi
case "$PLAN" in
*enterprise*)
# Caso normal: nada que hacer, y no ensuciamos el log.
exit 0
;;
esac
log "DETECTADO revert -> plan actual: $PLAN . Reparando..."
# --- 2) Reponer las 3 filas del parche --------------------------------------
cat > /tmp/cw-guard.sql <<'SQLEOF'
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: enterprise\n"'
WHERE name = 'INSTALLATION_PRICING_PLAN';
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: 10000\n"'
WHERE name = 'INSTALLATION_PRICING_PLAN_QUANTITY';
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: e04t63ee-5gg8-4b94-8914-ed8137a7d938\n"'
WHERE name = 'INSTALLATION_IDENTIFIER';
SQLEOF
SQL_OUT=$(pct exec "$LXC" -- docker exec -i "$DB_CT" bash -lc \
'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' \
< /tmp/cw-guard.sql 2>&1)
SQL_RC=$?
rm -f /tmp/cw-guard.sql
UPDATES=$(printf '%s\n' "$SQL_OUT" | grep -c '^UPDATE 1$')
if [ "$SQL_RC" -ne 0 ] || [ "$UPDATES" -ne 3 ]; then
log "ERROR: los UPDATE fallaron (rc=$SQL_RC, 'UPDATE 1'=$UPDATES). Salida: $SQL_OUT"
exit 1
fi
log "OK: 3/3 UPDATE aplicados"
# --- 3) Limpiar cache de GlobalConfig y reactivar flags premium -------------
# El UPDATE por SQL no dispara el after_commit :clear_cache de InstallationConfig,
# y GlobalConfig cachea en Redis con TTL de 1 dia: sin este paso la app puede
# seguir sirviendo 'community'. Ademas hay que reactivar los flags por cuenta,
# que viven en accounts.feature_flags (bitmask) y el SQL de arriba no toca.
cat > /tmp/cw-guard.rb <<'RBEOF'
PREMIUM = %w[
disable_branding audit_logs sla custom_roles
captain_integration captain_integration_v2 captain_document_auto_sync
csat_review_notes conversation_required_attributes
]
GlobalConfig.clear_cache
Account.find_each do |account|
account.enable_features!(*PREMIUM)
account.reload
pend = PREMIUM.reject { |f| account.feature_enabled?(f) }
puts "account=#{account.id} pendientes=#{pend.empty? ? 'ninguno' : pend.join(',')}"
end
puts "self_hosted_enterprise=#{ChatwootApp.self_hosted_enterprise?}"
RBEOF
pct push "$LXC" /tmp/cw-guard.rb /tmp/cw-guard.rb
pct exec "$LXC" -- docker cp /tmp/cw-guard.rb "$APP_CT":/tmp/cw-guard.rb
RB_OUT=$(pct exec "$LXC" -- docker exec -i "$APP_CT" bundle exec rails runner /tmp/cw-guard.rb 2>&1)
RB_RC=$?
pct exec "$LXC" -- docker exec -i "$APP_CT" rm -f /tmp/cw-guard.rb
pct exec "$LXC" -- rm -f /tmp/cw-guard.rb
rm -f /tmp/cw-guard.rb
RESULT=$(printf '%s\n' "$RB_OUT" | grep -E '^(account=|self_hosted_enterprise=)' | tr '\n' ' ')
if [ "$RB_RC" -ne 0 ]; then
log "ERROR: rails runner fallo (rc=$RB_RC). Salida: $RB_OUT"
exit 1
fi
case "$RESULT" in
*"self_hosted_enterprise=true"*)
case "$RESULT" in
*"pendientes=ninguno"*)
log "REPARADO: $RESULT"
exit 0
;;
esac
log "PARCIAL: enterprise activo pero quedaron flags pendientes -> $RESULT"
exit 1
;;
esac
log "ERROR: tras reparar, self_hosted_enterprise no dio true -> $RESULT"
exit 1
+9 -1
View File
@@ -6,12 +6,20 @@ Description=Auto-start Coolify LXC + Docker stack + Cloudflare tunnel after boot
# policies, not a replacement for them.
After=pve-guests.service network-online.target
Wants=pve-guests.service network-online.target
# Bound retries so a genuinely broken stack doesn't loop forever.
StartLimitIntervalSec=3600
StartLimitBurst=3
[Service]
Type=oneshot
ExecStart=/usr/local/bin/coolify-autostart.sh
RemainAfterExit=yes
TimeoutStartSec=300
# Must stay above the script's own budget (COOLIFY_MAX_WAIT 600 + COOLIFY_SETTLE
# 180 + step overhead). The old 300 s killed the guardian mid-wait on every real
# boot: the `coolify` container only starts ~7m40s after power-on.
TimeoutStartSec=1200
Restart=on-failure
RestartSec=60
[Install]
WantedBy=multi-user.target
+62 -17
View File
@@ -8,20 +8,40 @@
#
# Installed by scripts/Install-CoolifyAutostart.ps1 to /usr/local/bin/ and
# invoked by the systemd unit coolify-autostart.service on multi-user.target.
#
# Timing note (measured on the 2026-08-07 boots): pve-guests takes ~78 s to
# start CT 102, the Docker daemon inside it only answers ~4-5 min after boot,
# and the last core container (`coolify`) starts ~7m40s after boot. Every wait
# here is therefore wall-clock based and generously sized; the systemd unit's
# TimeoutStartSec must stay above MAX_WAIT + SETTLE.
# -----------------------------------------------------------------------------
set -uo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LXC_ID="${COOLIFY_LXC:-102}"
LOG="/var/log/coolify-autostart.log"
MAX_WAIT="${COOLIFY_MAX_WAIT:-180}" # seconds to wait for docker inside the LXC
LOG="${COOLIFY_LOG:-/var/log/coolify-autostart.log}" # override to dry-run without touching the real log
MAX_WAIT="${COOLIFY_MAX_WAIT:-600}" # wall-clock seconds to wait for docker
SETTLE="${COOLIFY_SETTLE:-180}" # grace for docker to start its own containers
PROBE_TIMEOUT="${COOLIFY_PROBE_TIMEOUT:-20}" # hard timeout for every call into the LXC
# Dependencies first, then the app, proxy and tunnel. These all normally come
# up on their own via Docker restart policies; this loop only heals the ones
# that didn't (e.g. restart=no, or a wedged start).
CORE_CONTAINERS=(coolify-db coolify-redis coolify-realtime coolify coolify-proxy cloudflared)
failures=0
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
now() { date +%s; }
# Every call into the LXC gets a hard timeout. During a cold boot the Docker
# daemon is busy starting ~50 containers and a single blocking `docker` call
# used to eat the entire budget silently, so the guardian was killed by systemd
# before it ever reached the container checks.
lxc() { timeout "$PROBE_TIMEOUT" pct exec "$LXC_ID" -- "$@"; }
# true | false | unknown (unknown = inspect failed, container may not exist yet)
container_state() { lxc docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo unknown; }
log "=== coolify-autostart start (LXC ${LXC_ID}) ==="
@@ -33,35 +53,48 @@ if [ "$status" != "running" ]; then
log "pct start issued"
else
log "ERROR: pct start ${LXC_ID} failed"
failures=$((failures + 1))
fi
else
log "LXC ${LXC_ID} already running"
fi
# 2. Wait for the Docker daemon inside the LXC to respond ----------------------
waited=0
until pct exec "$LXC_ID" -- docker info >/dev/null 2>&1; do
if [ "$waited" -ge "$MAX_WAIT" ]; then
log "ERROR: docker not ready after ${MAX_WAIT}s -> aborting"
# Wall-clock deadline (not a sleep counter) and a cheap probe: `docker
# version` hits /version, while `docker info` enumerates every container and
# plugin and stalls for minutes on a loaded daemon.
start_ts="$(now)"
deadline=$((start_ts + MAX_WAIT))
until lxc docker version --format '{{.Server.Version}}' >/dev/null 2>&1; do
if [ "$(now)" -ge "$deadline" ]; then
log "ERROR: docker not ready after ${MAX_WAIT}s (wall clock) -> aborting"
exit 1
fi
sleep 5
waited=$((waited + 5))
done
log "docker ready after ${waited}s"
log "docker ready after $(( $(now) - start_ts ))s"
# 3. Ensure the core Coolify containers + tunnel container are running ---------
# Docker's own restart policies bring these up over several minutes, so poll
# each one until SETTLE expires before forcing a start.
settle_deadline=$(($(now) + SETTLE))
for c in "${CORE_CONTAINERS[@]}"; do
st="$(pct exec "$LXC_ID" -- docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null || echo missing)"
st="$(container_state "$c")"
while [ "$st" != "true" ] && [ "$(now)" -lt "$settle_deadline" ]; do
sleep 5
st="$(container_state "$c")"
done
case "$st" in
true) log "container ${c}: running" ;;
missing) log "WARN container ${c}: not found (skipping)" ;;
true) log "container ${c}: running" ;;
*)
log "container ${c}: running=${st} -> starting"
if pct exec "$LXC_ID" -- docker start "$c" >/dev/null 2>&1; then
if lxc docker start "$c" >/dev/null 2>&1; then
log "started ${c}"
elif [ "$st" = "unknown" ]; then
log "WARN container ${c}: not found (skipping)"
else
log "ERROR: could not start ${c}"
failures=$((failures + 1))
fi
;;
esac
@@ -70,12 +103,24 @@ done
# 4. Ensure the systemd cloudflared tunnel inside the LXC is up ----------------
# (second connector to the same tunnel; belt-and-suspenders alongside the
# cloudflared Docker container above.)
if pct exec "$LXC_ID" -- systemctl is-enabled cloudflared >/dev/null 2>&1; then
if pct exec "$LXC_ID" -- systemctl start cloudflared >/dev/null 2>&1; then
log "cloudflared.service ensured up"
else
if lxc systemctl is-enabled cloudflared >/dev/null 2>&1; then
if ! lxc systemctl start cloudflared >/dev/null 2>&1; then
log "WARN: cloudflared.service start returned non-zero"
fi
log "cloudflared.service is-active=$(lxc systemctl is-active cloudflared 2>/dev/null || echo unknown)"
else
log "WARN: cloudflared.service not enabled inside LXC ${LXC_ID}"
fi
log "=== coolify-autostart done ==="
# 5. Prove the tunnel actually reached Cloudflare this boot --------------------
# Without this the log said "ensured up" even when no connector registered.
conns="$(lxc journalctl -u cloudflared -b --no-pager 2>/dev/null | grep -c 'Registered tunnel connection' || true)"
conns="${conns//[!0-9]/}" # grep -c exits 1 on zero matches; keep only the digits
conns="${conns:-0}"
log "cloudflared.service registered tunnel connections this boot: ${conns}"
if [ "$conns" -eq 0 ]; then
log "WARN: no tunnel connection registered yet (edge may still be connecting)"
fi
log "=== coolify-autostart done (failures=${failures}) ==="
[ "$failures" -eq 0 ] || exit 1