# Aplica el parche enterprise de Chatwoot alineado al comando original: # # docker exec -i "$(docker ps -q --filter 'name=pgvector')" psql \ # -U postgres -d chatwoot -c "UPDATE ... WHERE name = '...';" # # Convencion del repo: Proxmox 192.168.0.200 -> LXC 102 (coolify) -> docker. # # 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 { Join-Path $PSScriptRoot "..\keys\proxmox_ed25519" }) ) # Encoding UTF-8 sin BOM (BOM rompe el shebang #!/bin/bash en Linux). $utf8NoBom = New-Object System.Text.UTF8Encoding($false) $ErrorActionPreference = "Stop" if (-not (Test-Path -LiteralPath $SshKey)) { throw "No se encontro la clave SSH: $SshKey" } function Invoke-Remote { param([string]$RemoteCmd) $args = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey "root@$ProxmoxHost" $RemoteCmd ) 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 - (postgres-c11xzy...), asi que un patron # ".*postgres" nunca casa. El orden no importa con greps separados. $detect = @' #!/bin/bash pct exec __LXC__ -- bash -lc 'docker ps --format "{{.Names}}" | grep -F "__UUID__" | grep -Ei "(pgvector|postgres|db)" | head -n1' '@ $detect = $detect.Replace('__LXC__', $LxcId).Replace('__UUID__', $ServiceUuid) $tmpDetect = [IO.Path]::GetTempFileName() + ".sh" [IO.File]::WriteAllText($tmpDetect, $detect, $utf8NoBom) try { $localName = Split-Path -Leaf $tmpDetect $scpArgs = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey $tmpDetect "root@${ProxmoxHost}:/tmp/$localName" ) & scp @scpArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "scp detect fallo (exit $LASTEXITCODE)." } Write-Host "[*] Buscando contenedor Postgres de Chatwoot en LXC $LxcId ..." $cand = Invoke-Remote "bash /tmp/$localName" Invoke-Remote "rm -f /tmp/$localName" | Out-Null 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 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 } } Write-Host "[+] Contenedor: $Container" # --- 1b) Detectar credenciales reales desde docker inspect ---------------- $inspectScript = @' #!/bin/bash pct exec __LXC__ -- docker inspect __CT__ --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= ' /^POSTGRES_USER=/ { u=$2 } /^POSTGRES_DB=/ { d=$2 } /^POSTGRES_PASSWORD=/ { p=$2 } /^POSTGRES_HOST=/ { h=$2 } /^SERVICE_NAME_POSTGRES_5432_TCP=/ { skip=1; next } /^SERVICE_NAME_POSTGRES=/ { skip=1; next } /^SERVICE_USER_POSTGRES=/ { skip=1; next } /^SERVICE_PASSWORD_POSTGRES=/ { skip=1; next } /^COOLIFY_CONTAINER_NAME=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_5432_TCP_PORT=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_PORT=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_PORT_5432_TCP=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_PORT_5432_TCP_PROTO=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_PORT_5432_TCP_ADDR=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_PORT_5432_TCP_PORT=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_HOST=/ { skip=1; next } /^SERVICE_NAME_POSTGRES_/ { skip=1; next } /^POSTGRES_HOST=/ { skip=1; next } /^POSTGRES_PORT=/ { skip=1; next } /^POSTGRES_USERNAME=/ { skip=1; next } /^POSTGRES_PASSWORD=/ { skip=1; next } END { print "USER=" u; print "DB=" d; print "PASSWORD=" p } ' '@ $inspectScript = $inspectScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container) $tmpInspect = [IO.Path]::GetTempFileName() + ".sh" [IO.File]::WriteAllText($tmpInspect, $inspectScript, $utf8NoBom) try { $localName = Split-Path -Leaf $tmpInspect $scpArgs = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey $tmpInspect "root@${ProxmoxHost}:/tmp/$localName" ) & scp @scpArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "scp inspect fallo (exit $LASTEXITCODE)." } $envOut = Invoke-Remote "bash /tmp/$localName" Invoke-Remote "rm -f /tmp/$localName" | Out-Null } finally { Remove-Item -LiteralPath $tmpInspect -ErrorAction SilentlyContinue } $pgUser = ($envOut | Where-Object { $_ -match '^USER=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1 $pgDb = ($envOut | Where-Object { $_ -match '^DB=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1 $pgPass = ($envOut | Where-Object { $_ -match '^PASSWORD=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1 if (-not $pgUser) { $pgUser = "postgres" } if (-not $pgDb) { $pgDb = "chatwoot" } Write-Host ("[+] PG user={0} db={1} password={2}" -f $pgUser, $pgDb, ($pgPass -replace '.', '*')) # --- 2) SQL (3 sentencias identicas al comando original) ------------------- $sql = @' 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'; '@ # Script bash que envuelve el original: docker exec -i CT psql -U USER -d DB < sql. $applyScript = @' #!/bin/bash pct exec __LXC__ -- docker exec -i __CT__ env PGPASSWORD=__PASS__ psql -U __USER__ -d __DB__ -v ON_ERROR_STOP=1 < "$1" '@ $applyScript = $applyScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container).Replace('__USER__', $pgUser).Replace('__DB__', $pgDb).Replace('__PASS__', $pgPass) $verifyScript = @' #!/bin/bash pct exec __LXC__ -- docker exec -i __CT__ env PGPASSWORD=__PASS__ psql -U __USER__ -d __DB__ -c "SELECT name, serialized_value FROM public.installation_configs WHERE name IN ('INSTALLATION_PRICING_PLAN','INSTALLATION_PRICING_PLAN_QUANTITY','INSTALLATION_IDENTIFIER') ORDER BY name;" '@ $verifyScript = $verifyScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container).Replace('__USER__', $pgUser).Replace('__DB__', $pgDb).Replace('__PASS__', $pgPass) if ($DryRun) { Write-Host "[dry-run] SQL que se aplicaria:" Write-Host "------" Write-Host $sql Write-Host "------" Write-Host "[dry-run] apply.sh:" Write-Host "------" # 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 } # --- 3) Subir SQL + scripts y ejecutar ------------------------------------ $tmpSql = [IO.Path]::GetTempFileName() + ".sql" $tmpApply = [IO.Path]::GetTempFileName() + ".sh" $tmpVerify = [IO.Path]::GetTempFileName() + ".sh" $remoteSql = "/tmp/chatwoot-enterprise-patch.sql" $remoteApply = "/tmp/chatwoot-apply.sh" $remoteVerify = "/tmp/chatwoot-verify.sh" try { [IO.File]::WriteAllText($tmpSql, $sql, $utf8NoBom) [IO.File]::WriteAllText($tmpApply, $applyScript, $utf8NoBom) [IO.File]::WriteAllText($tmpVerify, $verifyScript, $utf8NoBom) # Subir SQL al HOST Proxmox (no al LXC; lo lee bash local y redirige a pct exec). $sqlLocal = Split-Path -Leaf $tmpSql $scpArgs = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey $tmpSql "root@${ProxmoxHost}:$remoteSql" ) & scp @scpArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "scp SQL fallo (exit $LASTEXITCODE)." } # Subir apply.sh y verify.sh al host. $applyLocal = Split-Path -Leaf $tmpApply $scpArgs = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey $tmpApply "root@${ProxmoxHost}:$remoteApply" ) & scp @scpArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "scp apply fallo (exit $LASTEXITCODE)." } $verifyLocal = Split-Path -Leaf $tmpVerify $scpArgs = @( "-o", "BatchMode=yes" "-o", "ConnectTimeout=15" "-o", "StrictHostKeyChecking=no" "-i", $SshKey $tmpVerify "root@${ProxmoxHost}:$remoteVerify" ) & scp @scpArgs | Out-Null if ($LASTEXITCODE -ne 0) { throw "scp verify fallo (exit $LASTEXITCODE)." } Write-Host "[*] Aplicando 3 UPDATE en $Container ..." $output = Invoke-Remote "bash $remoteApply $remoteSql" $exitCode = $LASTEXITCODE Write-Host "----- psql output -----" $output | ForEach-Object { Write-Host $_ } Write-Host "-----------------------" } finally { Remove-Item -LiteralPath $tmpSql, $tmpApply, $tmpVerify -ErrorAction SilentlyContinue } # --- 4) Validacion -------------------------------------------------------- $updateLines = @($output | Where-Object { $_ -match '^UPDATE\s+1\s*$' }) Write-Host ("UPDATE 1 count = {0}" -f $updateLines.Count) if ($exitCode -ne 0) { Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null throw "psql finalizo con exit code $exitCode." } if ($updateLines.Count -ne 3) { Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null throw "Se esperaban 3 lineas 'UPDATE 1', se obtuvieron $($updateLines.Count)." } # --- 5) Verificacion SELECT ----------------------------------------------- Write-Host "[*] Verificando valores finales ..." $verify = Invoke-Remote "bash $remoteVerify" $verify | ForEach-Object { Write-Host $_ } # Limpieza final. 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" }