Files
Proxmox-Coolify-Manager/scripts/Get-ChatwootLicenseStatus.ps1
T

251 lines
9.7 KiB
PowerShell

# 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"
}