398 lines
16 KiB
PowerShell
398 lines
16 KiB
PowerShell
# 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 <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 -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"
|
|
}
|