# 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 "" } # 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 "") { $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" }