251 lines
9.7 KiB
PowerShell
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"
|
|
}
|