Actualizar un servidor con el agente en una carpeta NO estandar terminaba en verde y lo dejaba con la version anterior. Todo el camino de Windows identificaba al agente por NOMBRE, mientras que lo unico que se actualiza se identifica por RUTA; en cuanto las dos no coincidian, nada fallaba y nada cambiaba. - Se alinea la tarea programada con el binario instalado. `Start-ScheduledTask` ejecuta la ruta registrada en su accion, no el -Prefix: si difieren, se copiaba el binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se reapunta conservando disparador, principal, ajustes y argumentos; si no se puede corregir, FALLA — arrancar a sabiendas el binario anterior es peor. - La confirmacion de arranque mira la RUTA del proceso. Un agente viejo que nunca se detuvo satisfacia igual de bien un `Get-Process -Name`. Si la ruta no es legible (un proceso de SYSTEM no la expone sin elevacion) se acepta por nombre y se avisa, en vez de revertir una actualizacion correcta por falta de informacion. - Corregido Merge-EnvFile con un config\.env de UNA linea: al asignar la salida de un `if`, PowerShell desenrolla un array de un elemento a escalar, asi que $lines.Count reventaba con Set-StrictMode y la siembra abortaba la instalacion. Nuevo scripts/emular-actualizacion-windows.ps1: monta un agente falso (un .exe real que se queda vivo), una instalacion en una carpeta y una tarea apuntando a otra, corre el instalador y dice si la actualizacion surtio efecto. Sin elevacion y sin tocar la instalacion real de la maquina. Es lo que destapo los dos defectos: contra el instalador anterior reproduce el sintoma exacto —codigo de salida 0 y "El agente esta corriendo con el binario nuevo" sobre un servidor intacto— y contra este confirma que ya surte efecto, sin tocar la tarea cuando ya estaba bien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
635 lines
29 KiB
PowerShell
635 lines
29 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Instalador Windows de CloudRestoreAS.
|
|
|
|
.DESCRIPTION
|
|
NO instala ni descarga NADA en el sistema: el binario es 100% autocontenido (Qt,
|
|
driver ODBC + Kerberos/OpenSSL, y 7-Zip van embebidos). Este script solo coloca el
|
|
.exe, hace el bootstrap de config\, opcionalmente siembra las credenciales del PANEL
|
|
y registra el arranque automático.
|
|
|
|
Por eso tampoco se usa NSSM ni ningún envoltorio de servicio descargado: el arranque
|
|
24/7 se resuelve con una tarea programada ONSTART, que ya viene en el SO.
|
|
|
|
Reemplazar el binario de un servidor en producción no puede dejarlo sin restaurador, así
|
|
que el script se sostiene sobre tres garantías, las mismas que install.sh:
|
|
1. No actúa si hay una restauración en curso (sale con 75, EX_TEMPFAIL).
|
|
2. Respalda el binario anterior antes de pisarlo.
|
|
3. Confirma que el agente volvió a arrancar y, si no, REVIERTE al binario anterior.
|
|
|
|
.PARAMETER Service
|
|
Arranque 24/7 sin sesión: tarea programada ONSTART como SYSTEM (recomendado en servidor).
|
|
|
|
.PARAMETER Desktop
|
|
Arranque al iniciar sesión. La app registra su propia tarea ONLOGON al ejecutarse.
|
|
|
|
.PARAMETER UpdateInPlace
|
|
Actualiza una instalación EXISTENTE: reemplaza el binario y vuelve a levantar el agente por
|
|
el mismo mecanismo con el que estaba, sin volver a registrar la tarea ni correr el bootstrap.
|
|
Es el modo que usa el PANEL para actualizar, porque no toca nada de la configuración vigente.
|
|
|
|
.PARAMETER Prefix
|
|
Carpeta destino. Default C:\Aduanasoft\CloudRestoreAS.
|
|
|
|
.PARAMETER PanelEnvFile
|
|
Archivo KEY=valor con CLOUDRESTORE_PANEL_API_URL / _API_TOKEN / _INSTANCE_KEY que se
|
|
fusiona en config\.env tras el bootstrap y luego se borra. Lo usa el instalador remoto
|
|
del PANEL para dejar el servidor configurado sin intervención.
|
|
|
|
.EXAMPLE
|
|
.\install.ps1 -Service
|
|
.EXAMPLE
|
|
.\install.ps1 -UpdateInPlace -Prefix 'D:\CloudRestoreAS'
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$Service,
|
|
[switch]$Desktop,
|
|
[switch]$UpdateInPlace,
|
|
[string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS',
|
|
[string]$PanelEnvFile = ''
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
Set-StrictMode -Version Latest
|
|
|
|
$BinName = 'CloudRestoreAS.exe'
|
|
$ProcName = 'CloudRestoreAS'
|
|
$TaskName = 'CloudRestoreAS'
|
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
|
|
# Código de salida reservado para "hay una restauración en curso". Es 75 (EX_TEMPFAIL) y no 1
|
|
# a propósito, igual que en install.sh: le dice al PANEL "reintenta luego", no "falló la
|
|
# instalación", y así el operador no sale a buscar una avería que no existe.
|
|
$EXIT_RESTORE_IN_PROGRESS = 75
|
|
|
|
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
|
function Write-Ok($msg) { Write-Host "OK: $msg" -ForegroundColor Green }
|
|
function Write-Warn($msg) { Write-Warning $msg }
|
|
|
|
$chosen = @()
|
|
if ($Service) { $chosen += '-Service' }
|
|
if ($Desktop) { $chosen += '-Desktop' }
|
|
if ($UpdateInPlace) { $chosen += '-UpdateInPlace' }
|
|
if ($chosen.Count -gt 1) {
|
|
throw "Elige un solo modo; se recibieron: $($chosen -join ', ')."
|
|
}
|
|
$Mode = if ($Service) { 'service' }
|
|
elseif ($Desktop) { 'desktop' }
|
|
elseif ($UpdateInPlace) { 'update-in-place' }
|
|
else { 'none' }
|
|
|
|
$dest = Join-Path $Prefix $BinName
|
|
# Respaldo del binario anterior. Se llena solo si hay algo que respaldar; el bloque final lo
|
|
# usa para revertir y lo borra cuando confirma que la versión nueva sí arrancó.
|
|
$backup = ''
|
|
|
|
# --- Utilidades sobre el estado del agente ------------------------------------------
|
|
|
|
function Get-AgentProcess {
|
|
Get-Process -Name $ProcName -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
function Get-AgentTask {
|
|
Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
function Get-NormalizedPath([string]$Path) {
|
|
<#
|
|
Normaliza para comparar rutas: la tarea puede guardarlas entrecomilladas, con otra
|
|
capitalización o con separador final. Comparar las cadenas en crudo daba falsos negativos.
|
|
#>
|
|
if (-not $Path) { return '' }
|
|
$clean = $Path.Trim().Trim('"')
|
|
try { return [System.IO.Path]::GetFullPath($clean).TrimEnd('\') } catch { return $clean.TrimEnd('\') }
|
|
}
|
|
|
|
function Test-SamePath([string]$A, [string]$B) {
|
|
if (-not $A -or -not $B) { return $false }
|
|
# NTFS no distingue mayúsculas: comparar sensible a caso reportaría dos rutas distintas donde
|
|
# el SO ve una sola.
|
|
return (Get-NormalizedPath $A) -ieq (Get-NormalizedPath $B)
|
|
}
|
|
|
|
function Get-AgentTaskExecute {
|
|
<#
|
|
Ruta del ejecutable que la tarea programada tiene registrada en su acción.
|
|
|
|
Este es el dato que faltaba. Reemplazar el binario es una operación por RUTA, pero
|
|
arrancarlo con `Start-ScheduledTask` es una operación por NOMBRE. Si la tarea apunta a otra
|
|
carpeta se actualiza un binario y se arranca otro: el servidor se queda en la versión vieja
|
|
y la actualización parece haber funcionado.
|
|
|
|
Se lee la propiedad por reflexión porque no todas las acciones de una tarea son de tipo
|
|
Exec (una ComHandler no tiene `Execute`), y con Set-StrictMode tocar una propiedad que no
|
|
existe es un error.
|
|
#>
|
|
$task = Get-AgentTask
|
|
if (-not $task) { return '' }
|
|
foreach ($action in @($task.Actions)) {
|
|
$prop = $action.PSObject.Properties['Execute']
|
|
if ($prop -and $prop.Value) { return ([string]$prop.Value).Trim().Trim('"') }
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function Repair-AgentTaskPath {
|
|
<#
|
|
Reapunta la acción de la tarea al binario recién instalado, conservando el resto de su
|
|
definición (disparador, principal, ajustes) y los argumentos que tuviera: volver a
|
|
registrarla desde cero perdería cualquier ajuste que el operador le haya hecho.
|
|
#>
|
|
param($Task)
|
|
|
|
$actions = @()
|
|
foreach ($action in @($Task.Actions)) {
|
|
$execProp = $action.PSObject.Properties['Execute']
|
|
if (-not $execProp) { $actions += $action; continue }
|
|
|
|
$argProp = $action.PSObject.Properties['Arguments']
|
|
$arguments = if ($argProp -and $argProp.Value) { [string]$argProp.Value } else { '' }
|
|
$actions += if ($arguments) {
|
|
New-ScheduledTaskAction -Execute $dest -Argument $arguments -WorkingDirectory $Prefix
|
|
} else {
|
|
New-ScheduledTaskAction -Execute $dest -WorkingDirectory $Prefix
|
|
}
|
|
}
|
|
|
|
try {
|
|
Set-ScheduledTask -TaskName $TaskName -Action $actions -ErrorAction Stop | Out-Null
|
|
Write-Ok "Tarea '$TaskName' reapuntada a $dest."
|
|
} catch {
|
|
throw ("La tarea '$TaskName' ejecuta un binario distinto del que se acaba de instalar y no " +
|
|
"se pudo corregir ($($_.Exception.Message)). Arrancarla levantaría la versión anterior " +
|
|
'y la actualización quedaría sin efecto aparentando haber funcionado, que es peor que ' +
|
|
'fallar aquí.')
|
|
}
|
|
}
|
|
|
|
function Get-AgentState {
|
|
<#
|
|
Estado del agente en una sola consulta: si hay algo vivo, si alguno corre el binario de
|
|
ESTA instalación, y desde dónde corren los que no.
|
|
|
|
`PathsReadable` separa "no corre desde aquí" de "no pude ver desde dónde corre": un proceso
|
|
de SYSTEM no expone `.Path` a una cuenta sin elevación, y tratar ese caso como "es de otra
|
|
instalación" revertiría actualizaciones correctas.
|
|
|
|
Vive en un solo sitio a propósito. Esta lógica repetida en dos funciones es exactamente
|
|
cómo se colaron las divergencias que estamos arreglando.
|
|
#>
|
|
$procs = @(Get-AgentProcess)
|
|
$paths = @()
|
|
foreach ($proc in $procs) {
|
|
try { if ($proc.Path) { $paths += $proc.Path } } catch { }
|
|
}
|
|
$fromPrefix = $false
|
|
foreach ($path in $paths) {
|
|
if (Test-SamePath $path $dest) { $fromPrefix = $true; break }
|
|
}
|
|
return [pscustomobject]@{
|
|
Running = ($procs.Count -gt 0)
|
|
PathsReadable = (($procs.Count -eq 0) -or ($paths.Count -gt 0))
|
|
FromPrefix = $fromPrefix
|
|
Paths = $paths
|
|
}
|
|
}
|
|
|
|
function Test-RestoreInProgress {
|
|
<#
|
|
Una restauración en curso no se interrumpe. El agente no atiende señales de terminación,
|
|
así que matarlo a media restauración deja el job atascado —ese ZIP queda vetado en cada
|
|
escaneo posterior— y puede dejar la base en SINGLE_USER. Cualquier subcarpeta de Temp\
|
|
es un job en vuelo.
|
|
#>
|
|
$temp = Join-Path $Prefix 'Temp'
|
|
if (-not (Test-Path -LiteralPath $temp)) { return $false }
|
|
$first = Get-ChildItem -LiteralPath $temp -Force -ErrorAction SilentlyContinue |
|
|
Select-Object -First 1
|
|
return [bool]$first
|
|
}
|
|
|
|
function Stop-Agent {
|
|
<#
|
|
Detiene tarea y procesos, y espera a que el SO libere el .exe. Devuelve $true si había
|
|
algo corriendo.
|
|
|
|
La espera no es cosmética: mientras un proceso tenga el binario mapeado, Copy-Item falla
|
|
y la actualización aborta. Windows no tiene el truco que hace fácil esto en POSIX —donde
|
|
`install` desvincula el destino antes de crearlo, por lo que reemplazar un binario EN USO
|
|
funciona—, así que aquí no queda más que esperar de verdad. Los 5s de antes se quedaban
|
|
cortos con un antivirus escaneando un onefile de ~270 MB.
|
|
#>
|
|
param([int]$TimeoutSeconds = 30)
|
|
|
|
$running = [bool](Get-AgentProcess)
|
|
if (Get-AgentTask) {
|
|
Write-Step 'Deteniendo tarea programada existente'
|
|
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
}
|
|
Get-AgentProcess | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
while ((Get-AgentProcess) -and (Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
if (Get-AgentProcess) {
|
|
throw ("Hay procesos $ProcName que siguen vivos tras ${TimeoutSeconds}s. No se reemplaza " +
|
|
'el binario: la copia fallaría y dejaría la instalación a medias.')
|
|
}
|
|
return $running
|
|
}
|
|
|
|
function Start-Agent {
|
|
<#
|
|
Vuelve a levantar el agente por el mismo mecanismo con el que estaba: la tarea si está
|
|
registrada, y si no, el proceso suelto. Devuelve la etiqueta del mecanismo usado.
|
|
|
|
No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin
|
|
'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM
|
|
en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana.
|
|
|
|
Que la acción de la tarea apunte a `$dest` lo garantiza `Sync-AgentTaskPath`, que corre
|
|
justo después de copiar el binario. Aquí ya se puede arrancar sin volver a comprobarlo.
|
|
#>
|
|
if (Get-AgentTask) {
|
|
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
return 'tarea programada'
|
|
}
|
|
Start-Process -FilePath $dest -ArgumentList '--start-engine', '--headless' `
|
|
-WorkingDirectory $Prefix -WindowStyle Hidden
|
|
return 'proceso suelto'
|
|
}
|
|
|
|
function Wait-AgentAlive {
|
|
<#
|
|
Espera a que el agente esté vivo, y que sea el binario de ESTA instalación.
|
|
|
|
La identidad importa: `Get-Process -Name` responde "hay un proceso con ese nombre", no
|
|
"corre el binario que instalé". Con la tarea apuntando a otra carpeta, el agente viejo
|
|
—que nunca se fue— satisfacía la comprobación por nombre y la actualización pasaba por
|
|
buena sin haber cambiado nada.
|
|
|
|
Si la ruta del proceso no es legible NO se concluye que sea de otra instalación: un proceso
|
|
de SYSTEM no expone `.Path` a una cuenta sin elevación. Se acepta por nombre y se avisa,
|
|
porque revertir una actualización correcta por falta de información es peor.
|
|
#>
|
|
param([int]$TimeoutSeconds = 60)
|
|
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
$foreign = @()
|
|
while ((Get-Date) -lt $deadline) {
|
|
$state = Get-AgentState
|
|
if ($state.Running) {
|
|
if (-not $state.PathsReadable) {
|
|
Write-Warn ("Hay $ProcName corriendo pero no se pudo leer su ruta; se acepta por " +
|
|
'nombre. La versión efectiva la confirma el panel con config\.version.')
|
|
return $true
|
|
}
|
|
if ($state.FromPrefix) { return $true }
|
|
$foreign = $state.Paths
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
# Se avisa una sola vez y al final: dentro del bucle serían treinta líneas iguales.
|
|
if ($foreign.Count -gt 0) {
|
|
Write-Warn ("Hay $ProcName corriendo desde $($foreign -join ', '), pero no desde $dest.")
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Sync-AgentTaskPath {
|
|
<#
|
|
Alinea la acción de la tarea programada con el binario recién instalado.
|
|
|
|
Es la raíz del fallo silencioso: reemplazar el binario es una operación por RUTA, pero
|
|
`Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. En una
|
|
instalación fuera de la carpeta por omisión, eso significaba copiar el binario nuevo en un
|
|
sitio y arrancar el viejo desde otro: el run terminaba en verde y el servidor seguía igual.
|
|
#>
|
|
$registered = Get-AgentTaskExecute
|
|
if (-not $registered) { return }
|
|
if (Test-SamePath $registered $dest) { return }
|
|
|
|
Write-Warn "La tarea '$TaskName' ejecutaba $registered, no $dest."
|
|
Repair-AgentTaskPath -Task (Get-AgentTask)
|
|
}
|
|
|
|
function Copy-Binary {
|
|
<#
|
|
Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden
|
|
mantener el .exe abierto unos segundos más; reintentar sale mucho más barato que abortar
|
|
una actualización que iba bien.
|
|
#>
|
|
param([string]$From, [string]$To, [int]$Attempts = 5)
|
|
|
|
for ($i = 1; $i -le $Attempts; $i++) {
|
|
try {
|
|
Copy-Item -LiteralPath $From -Destination $To -Force -ErrorAction Stop
|
|
return
|
|
} catch {
|
|
if ($i -eq $Attempts) { throw }
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Localizar el binario (mismas rutas candidatas que install.sh) -------------------
|
|
$candidates = @(
|
|
(Join-Path $ScriptDir "dist\$BinName"),
|
|
(Join-Path $ScriptDir $BinName)
|
|
)
|
|
$src = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
|
|
if (-not $src) {
|
|
throw "No se encontró el binario ($BinName). Ejecuta .\build.ps1 primero."
|
|
}
|
|
|
|
# -Service crea una tarea como SYSTEM: requiere elevación.
|
|
#
|
|
# Se distingue "no es administrador" de "es administrador pero UAC le dio el token FILTRADO",
|
|
# que es lo que recibe una cuenta administradora que entra por OpenSSH cuando el destino no
|
|
# tiene LocalAccountTokenFilterPolicy. Los dos casos se veían idénticos —"no eres admin"— y el
|
|
# remedio es opuesto: en el primero hay que cambiar de cuenta; en el segundo la cuenta ya es la
|
|
# correcta y lo que falta es una política del servidor.
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$currentPrincipal = [Security.Principal.WindowsPrincipal]$identity
|
|
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
$adminSid = New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544'
|
|
$inAdminGroup = [bool]($identity.Groups | Where-Object { $_ -eq $adminSid })
|
|
|
|
if ($Mode -eq 'service' -and -not $isAdmin) {
|
|
if ($inAdminGroup) {
|
|
throw ("La cuenta '$($identity.Name)' SÍ pertenece a Administradores, pero esta sesión " +
|
|
'recibió el token filtrado por UAC, así que no puede registrar la tarea ONSTART como ' +
|
|
'SYSTEM. No hace falta cambiar de cuenta: hay que permitir la elevación remota en el ' +
|
|
'servidor (LocalAccountTokenFilterPolicy=1 en ' +
|
|
'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System), o usar -UpdateInPlace ' +
|
|
'si solo se está actualizando una instalación que ya existe.')
|
|
}
|
|
throw ("La cuenta '$($identity.Name)' no es Administrador y -Service registra una tarea " +
|
|
'ONSTART como SYSTEM, que exige elevación.')
|
|
}
|
|
|
|
Write-Host '===============================================' -ForegroundColor Cyan
|
|
Write-Host 'CloudRestoreAS - Instalación Windows'
|
|
Write-Host " Binario : $src"
|
|
Write-Host " Destino : $Prefix"
|
|
Write-Host " Modo : $Mode"
|
|
Write-Host '===============================================' -ForegroundColor Cyan
|
|
|
|
if ($Mode -eq 'update-in-place' -and -not (Test-Path -LiteralPath $dest)) {
|
|
throw ("Se pidió -UpdateInPlace pero en $Prefix no hay una instalación ($BinName no existe). " +
|
|
'Para una instalación nueva usa -Service, -Desktop o sin modo.')
|
|
}
|
|
|
|
# --- Guarda: no interrumpir una restauración en curso -------------------------------
|
|
# Va ANTES de detener nada y en TODOS los modos: el daño lo hace matar al agente, no el modo
|
|
# que se haya pedido. Antes esta comprobación no existía en Windows y una reinstalación a
|
|
# destiempo se llevaba por delante el respaldo que estuviera restaurando.
|
|
if (Test-RestoreInProgress) {
|
|
Write-Warn "Hay una restauración en curso ($Prefix\Temp no está vacío)."
|
|
Write-Warn 'No se instala para no dejarla a medias. Reintenta cuando termine.'
|
|
exit $EXIT_RESTORE_IN_PROGRESS
|
|
}
|
|
|
|
# --- Colocar el binario -------------------------------------------------------------
|
|
Write-Step 'Instalando binario'
|
|
New-Item -ItemType Directory -Path $Prefix -Force | Out-Null
|
|
|
|
$taskExisted = [bool](Get-AgentTask)
|
|
$wasRunning = Stop-Agent
|
|
|
|
# Respaldo para poder volver atrás. Se hace siempre que haya algo que pisar, no solo al
|
|
# actualizar en sitio: el PANEL actualiza con -Service, y esa vía también reemplaza el binario
|
|
# de un servidor en producción. Sin respaldo, un binario nuevo que no arranque deja el servidor
|
|
# sin agente y sin forma de recuperarlo salvo entrando a mano.
|
|
if (Test-Path -LiteralPath $dest) {
|
|
$backup = Join-Path $Prefix ".$BinName.prev"
|
|
try {
|
|
Copy-Item -LiteralPath $dest -Destination $backup -Force -ErrorAction Stop
|
|
Write-Ok "Respaldo del binario actual en $backup"
|
|
} catch {
|
|
throw ("No se pudo respaldar el binario actual en $backup ($($_.Exception.Message)). " +
|
|
'Se aborta: actualizar sin poder revertir no es aceptable.')
|
|
}
|
|
}
|
|
|
|
Copy-Binary -From $src -To $dest
|
|
Write-Ok "Binario instalado en $dest"
|
|
|
|
# El binario nuevo ya está en su sitio; falta que el arranque automático apunte AHÍ. Va aquí y no
|
|
# dentro de Start-Agent para que se corrija aunque en este momento no haya que arrancar nada: una
|
|
# tarea desalineada seguiría levantando la versión vieja en el próximo reinicio del servidor.
|
|
Sync-AgentTaskPath
|
|
|
|
# --- Bootstrap de config\ -----------------------------------------------------------
|
|
# El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre
|
|
# una vez acotado por timeout para que el operador ya pueda editar config\.env.
|
|
#
|
|
# Al actualizar en sitio se OMITE: el binario hace ensure_runtime_layout() en cada arranque, así
|
|
# que es redundante, y correr una segunda instancia junto a la viva es peligroso —al arrancar, el
|
|
# motor purga todas las subcarpetas de Temp, que son de la instancia en curso—.
|
|
if ($Mode -ne 'update-in-place') {
|
|
Write-Step 'Inicializando config\ (bootstrap)'
|
|
Push-Location $Prefix
|
|
try {
|
|
$env:QT_QPA_PLATFORM = 'offscreen'
|
|
$proc = Start-Process -FilePath $dest -ArgumentList '--headless' -PassThru -WindowStyle Hidden
|
|
if (-not $proc.WaitForExit(20000)) {
|
|
$proc | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} finally {
|
|
Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue
|
|
Pop-Location
|
|
}
|
|
}
|
|
|
|
$envPath = Join-Path $Prefix 'config\.env'
|
|
if (Test-Path -LiteralPath $envPath) {
|
|
Write-Ok 'config\.env presente.'
|
|
} else {
|
|
Write-Warn 'config\.env se creará en la primera ejecución.'
|
|
}
|
|
|
|
# --- Siembra de credenciales del PANEL ----------------------------------------------
|
|
# Fusión replace-or-append: respeta el resto de config\.env y es idempotente, así que
|
|
# reinstalar no duplica claves ni pierde ajustes locales.
|
|
function Merge-EnvFile {
|
|
param([string]$Target, [hashtable]$Values)
|
|
|
|
# El @() envuelve el `if` COMPLETO, no solo el Get-Content. Al asignar la salida de un `if`,
|
|
# PowerShell desenrolla un array de un solo elemento a escalar: con un config\.env de UNA línea
|
|
# $lines quedaba como String y `$lines.Count` reventaba con Set-StrictMode. Lo destapó la
|
|
# emulación de una actualización sobre una instalación con .env mínimo.
|
|
$lines = @(
|
|
if (Test-Path -LiteralPath $Target) { Get-Content -LiteralPath $Target -Encoding UTF8 }
|
|
)
|
|
|
|
foreach ($key in $Values.Keys) {
|
|
$line = "$key=$($Values[$key])"
|
|
$idx = -1
|
|
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
if ($lines[$i] -match "^\s*$([regex]::Escape($key))\s*=") { $idx = $i; break }
|
|
}
|
|
if ($idx -ge 0) { $lines[$idx] = $line } else { $lines += $line }
|
|
}
|
|
|
|
# Se escribe SIN BOM a propósito. `Set-Content -Encoding UTF8` en PowerShell 5.1 agrega BOM
|
|
# (EF BB BF), y python-dotenv abre el archivo con encoding utf-8 (no utf-8-sig), así que el
|
|
# BOM se pega a la primera línea. Si esa primera línea es una clave —lo que pasa cuando el
|
|
# bootstrap no alcanzó a crear config\.env y este archivo se genera desde cero— la clave
|
|
# queda ilegible para el agente: se instalaría sin conectarse al panel, con toda la
|
|
# apariencia de un error de captura.
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllLines($Target, [string[]]$lines, $utf8NoBom)
|
|
}
|
|
|
|
if ($PanelEnvFile) {
|
|
if (-not (Test-Path -LiteralPath $PanelEnvFile)) {
|
|
throw "No existe el archivo indicado en -PanelEnvFile: $PanelEnvFile"
|
|
}
|
|
Write-Step 'Sembrando credenciales del PANEL en config\.env'
|
|
$allowed = @(
|
|
'CLOUDRESTORE_PANEL_API_URL',
|
|
'CLOUDRESTORE_PANEL_API_TOKEN',
|
|
'CLOUDRESTORE_PANEL_INSTANCE_KEY',
|
|
'CLOUDRESTORE_PANEL_VERIFY_SSL'
|
|
)
|
|
$values = @{}
|
|
foreach ($raw in Get-Content -LiteralPath $PanelEnvFile -Encoding UTF8) {
|
|
$line = $raw.Trim()
|
|
if (-not $line -or $line.StartsWith('#')) { continue }
|
|
$eq = $line.IndexOf('=')
|
|
if ($eq -lt 1) { continue }
|
|
$key = $line.Substring(0, $eq).Trim()
|
|
# Lista blanca: el archivo viene de la red, no debe poder inyectar otras claves.
|
|
if ($allowed -contains $key) {
|
|
$values[$key] = $line.Substring($eq + 1).Trim()
|
|
}
|
|
}
|
|
if ($values.Count -gt 0) {
|
|
# En update-in-place el bootstrap no corrió, así que config\.env puede no existir todavía
|
|
# si la instalación previa nunca llegó a arrancar. Merge-EnvFile lo crea.
|
|
New-Item -ItemType Directory -Path (Split-Path -Parent $envPath) -Force | Out-Null
|
|
Merge-EnvFile -Target $envPath -Values $values
|
|
Write-Ok "$($values.Count) clave(s) del PANEL escritas en config\.env"
|
|
} else {
|
|
Write-Warn 'El archivo -PanelEnvFile no traía claves CLOUDRESTORE_PANEL_* válidas.'
|
|
}
|
|
# El archivo trae el token en claro: se borra en cuanto se consumió.
|
|
Remove-Item -LiteralPath $PanelEnvFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# --- Arranque automático ------------------------------------------------------------
|
|
switch ($Mode) {
|
|
'service' {
|
|
Write-Step 'Registrando tarea programada ONSTART (SYSTEM)'
|
|
# Sin NSSM: una tarea ONSTART como SYSTEM cubre el 24/7 headless con lo que ya
|
|
# trae el SO. El binario elige el plugin Qt 'offscreen' por `--headless`, que es lo que
|
|
# le permite correr en la sesión 0, donde SYSTEM no tiene escritorio interactivo.
|
|
#
|
|
# Correr como SYSTEM es además lo que evita aquí el problema de propiedad que en Linux
|
|
# sí hay que resolver: allá el instalador crea el árbol como root pero el unit corre
|
|
# como un usuario común, que no podría leer su config/.env ni escribir su base local
|
|
# (de ahí el `chown -R` de install.sh). SYSTEM tiene control total sobre el sistema de
|
|
# archivos local y este script no restringe ninguna ACL del prefijo, así que hereda de
|
|
# su carpeta padre y puede leer y escribir todo lo que creó el Administrador. No hace
|
|
# falta un arreglo simétrico; si algún día se cambia el principal a una cuenta común,
|
|
# entonces sí habría que ajustar los permisos del árbol.
|
|
$action = New-ScheduledTaskAction -Execute $dest `
|
|
-Argument '--start-engine --headless' -WorkingDirectory $Prefix
|
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
|
$taskPrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount `
|
|
-RunLevel Highest
|
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
|
-DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) `
|
|
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
|
|
|
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
|
-Principal $taskPrincipal -Settings $settings -Force | Out-Null
|
|
Start-ScheduledTask -TaskName $TaskName
|
|
Write-Ok "Tarea '$TaskName' registrada y arrancada."
|
|
}
|
|
'desktop' {
|
|
# La app registra su propia tarea ONLOGON la primera vez que corre EN la sesión del
|
|
# usuario. Desde aquí no se puede hacer por él: esta sesión no es la suya.
|
|
Write-Host 'Modo escritorio: el autostart ONLOGON queda registrado la primera vez que la'
|
|
Write-Host 'app se abra en la sesión del usuario.'
|
|
}
|
|
'update-in-place' {
|
|
# La tarea ya está registrada y no cambia: no se vuelve a definir ni se toca su
|
|
# configuración. El binario nuevo ya está en su sitio; el bloque de abajo se encarga de
|
|
# que el agente vuelva a levantarse con él.
|
|
Write-Host 'Actualización en sitio: la tarea programada existente se conserva tal cual.'
|
|
}
|
|
'none' {
|
|
Write-Host 'Instalación sin arranque automático.'
|
|
}
|
|
}
|
|
|
|
# --- Confirmar que el agente quedó corriendo, o revertir ----------------------------
|
|
# Esto es lo que faltaba: antes SOLO el modo -Service arrancaba algo, mientras que la detención
|
|
# de arriba corría en todos los modos. Actualizar con 'desktop' o 'none' mataba el agente y se
|
|
# iba, dejando el servidor sin restaurador y sin ninguna señal de que eso había pasado.
|
|
$shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted
|
|
|
|
if ($shouldBeRunning) {
|
|
# La condición mira la RUTA, no solo el nombre. Mientras se copiaba, la tarea pudo relanzar el
|
|
# agente desde su ruta anterior (trae RestartCount): con una comprobación por nombre eso pasaba
|
|
# por "ya está corriendo", no se arrancaba el nuevo, y la actualización quedaba sin efecto.
|
|
$state = Get-AgentState
|
|
if (-not ($state.Running -and ($state.FromPrefix -or -not $state.PathsReadable))) {
|
|
if ($state.Running) {
|
|
Write-Warn ("Hay $ProcName corriendo desde $($state.Paths -join ', '); se termina para " +
|
|
'que no compita con el binario recién instalado.')
|
|
Stop-Agent -TimeoutSeconds 15 | Out-Null
|
|
}
|
|
$how = Start-Agent
|
|
Write-Step "Rearrancando el agente ($how)"
|
|
}
|
|
if (Wait-AgentAlive -TimeoutSeconds 60) {
|
|
Write-Ok 'El agente está corriendo con el binario nuevo.'
|
|
if ($backup -and (Test-Path -LiteralPath $backup)) {
|
|
# Solo tras confirmar que la versión nueva corre se descarta el respaldo.
|
|
Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} else {
|
|
Write-Warn 'El agente no volvió a arrancar tras la actualización.'
|
|
$reverted = $false
|
|
if ($backup -and (Test-Path -LiteralPath $backup)) {
|
|
Write-Warn 'Revirtiendo al binario anterior...'
|
|
try {
|
|
Copy-Binary -From $backup -To $dest
|
|
Start-Agent | Out-Null
|
|
$reverted = Wait-AgentAlive -TimeoutSeconds 60
|
|
} catch {
|
|
$reverted = $false
|
|
}
|
|
}
|
|
if ($reverted) {
|
|
throw ('La versión nueva no arrancó, así que se REVIRTIÓ: el binario anterior está ' +
|
|
"corriendo de nuevo y el servidor quedó como estaba. La versión nueva NO se " +
|
|
"aplicó. Revisa $Prefix\config\logs para ver por qué no levantó.")
|
|
}
|
|
$backupNote = if ($backup) { $backup } else { '(sin respaldo)' }
|
|
throw ('La instalación no dejó al agente corriendo y no se pudo revertir. El binario ' +
|
|
"nuevo está en $dest y el anterior en $backupNote. Revisa $Prefix\config\logs.")
|
|
}
|
|
} elseif ($backup -and (Test-Path -LiteralPath $backup)) {
|
|
# No había agente corriendo ni tarea que relevar, así que no hay arranque que confirmar y el
|
|
# respaldo ya no protege de nada. Se borra: son ~270 MB, y dejarlos ahí para siempre convierte
|
|
# cada reinstalación en un cobro de disco silencioso.
|
|
Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host "Configuración : $envPath"
|
|
Write-Host "Logs : $Prefix\config\logs"
|
|
Write-Host 'Listo.'
|
|
|
|
# Salida explícita. El PANEL invoca este script como `& install.ps1 ...; exit $LASTEXITCODE`, y
|
|
# sin un `exit` propio esa variable queda sin fijar en el camino de éxito —aquí no corre ningún
|
|
# comando nativo que la establezca—, así que el código de salida dependería de lo que hubiera
|
|
# quedado en la sesión. Un 0 explícito no deja lugar a esa ambigüedad.
|
|
exit 0
|