Actualiza toolkit operativo y documentación
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user