2 Commits

Author SHA1 Message Date
0a62b7d0aa docs: documentar el instalador de Windows y corregir lo que contradecía al código
README afirmaba que la app no puede correr como servicio de Windows y sugería
NSSM, contradiciendo a install.ps1 desde que existe. LEEME.txt solo documentaba
el camino manual para Windows, mientras que para Linux ya traía el instalador.

BUILD.md gana -UpdateInPlace, las tres garantías al reemplazar el binario y el
remedio del token filtrado por UAC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:28 -06:00
c2afa52d6f fix(install): actualización desatendida en Windows, con reversión
install.sh recibió la maquinaria de actualización segura y install.ps1 nunca
recibió el equivalente. La asimetría se notaba en producción: actualizar desde
el PANEL dejaba el servidor sin agente.

- -UpdateInPlace: actualiza conservando la tarea y la configuración, sin correr
  el bootstrap (una segunda instancia purga el Temp\ de la que está viva).
- No se interrumpe una restauración en curso: sale con 75 (EX_TEMPFAIL), que el
  PANEL traduce a "reintenta luego". Windows no tenía esta guarda y una
  reinstalación a destiempo dejaba el respaldo vetado y la base en SINGLE_USER.
- Respaldo del binario anterior y reversión automática si el nuevo no arranca.
- Rearranque garantizado en TODOS los modos: la detención corría siempre, pero
  solo -Service volvía a arrancar algo.
- Espera de liberación del .exe de 5s a 30s con reintentos de la copia.
- Se distingue "no es administrador" de "es administrador con el token filtrado
  por UAC", que es lo que recibe una sesión de OpenSSH. Se veían idénticos y el
  remedio es el opuesto.

Y --headless deja de ser un no-op en Windows: _ensure_qt_platform() salía de
inmediato en win32, así que la tarea ONSTART arrancaba como SYSTEM en la sesión 0
con el plugin Qt 'windows' intentando crear una ventana real. En Linux el unit
fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría escondió el defecto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:12 -06:00
8 changed files with 534 additions and 63 deletions

View File

@@ -194,9 +194,10 @@ sudo systemctl restart cloudrestoreas # tras editar config/.env
Expand-Archive CloudRestoreAS-<version>-win-x86_64.zip -DestinationPath .
cd CloudRestoreAS
.\install.ps1 -Service # tarea programada ONSTART como SYSTEM (24/7 headless)
.\install.ps1 -Desktop # arranque al iniciar sesión (tarea ONLOGON de la app)
.\install.ps1 # solo instala + bootstrap
.\install.ps1 -Service # tarea programada ONSTART como SYSTEM (24/7 headless)
.\install.ps1 -Desktop # arranque al iniciar sesión (tarea ONLOGON de la app)
.\install.ps1 -UpdateInPlace # actualiza una instalación existente, conservando su tarea
.\install.ps1 # solo instala + bootstrap
Get-Help .\install.ps1 -Detailed
```
Parámetros: `-Prefix` (default `C:\Aduanasoft\CloudRestoreAS`), `-PanelEnvFile`.
@@ -205,6 +206,30 @@ Parámetros: `-Prefix` (default `C:\Aduanasoft\CloudRestoreAS`), `-PanelEnvFile`
arranque 24/7 se resuelve con una tarea programada, no con NSSM: descargarlo violaría la
regla de que en el servidor destino no se instala ni se baja nada.
Si la cuenta pertenece a Administradores y aun así se rechaza, el mensaje lo dice explícitamente:
es el **token filtrado por UAC**, que es lo que recibe una sesión de OpenSSH. No se arregla
cambiando de cuenta sino con `LocalAccountTokenFilterPolicy=1` (DWORD) en
`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`.
### Garantías al reemplazar el binario (las mismas en los dos instaladores)
Reemplazar el binario de un servidor en producción no puede dejarlo sin restaurador:
1. **No se actúa si hay una restauración en curso** (`Temp\` no vacío): se sale con **75**
(`EX_TEMPFAIL`), que el PANEL traduce a "reintenta luego" y no a "falló la instalación".
Interrumpirla dejaría ese respaldo vetado en cada escaneo posterior y la base en `SINGLE_USER`.
2. **Se respalda el binario anterior** antes de pisarlo (`.CloudRestoreAS.exe.prev`).
3. **Se confirma que el agente volvió a arrancar** y, si no, se **revierte** al binario anterior.
El respaldo solo se descarta tras esa confirmación.
`-UpdateInPlace` además no vuelve a registrar la tarea (así no pisa ajustes hechos sobre ella) y
se salta el bootstrap: una segunda instancia purgaría el `Temp\` de la que está viva. Es el modo
que usa el PANEL para actualizar.
El arranque headless no depende del entorno de la tarea: `--headless` hace que el binario elija
el plugin Qt `offscreen` en cualquier plataforma, que es lo que le permite correr como SYSTEM en
la sesión 0, donde no hay escritorio interactivo.
> `scripts/dev-setup.ps1` es otra cosa: prepara el entorno de **desarrollo** (Python, venv,
> `requirements.txt`) para correr `python runner.py`. No sirve para desplegar el binario.

View File

@@ -1,5 +1,46 @@
# Changelog
## [Sin publicar]
### Instalación y actualización desatendidas en Windows
El instalador de Windows nunca recibió la maquinaria de actualización segura que sí tiene
`install.sh`, y la asimetría se notaba en producción: actualizar desde el PANEL dejaba el
servidor sin agente, o fallaba con un error que no correspondía.
- **`install.ps1 -UpdateInPlace`**: actualiza una instalación existente conservando su tarea
programada y su configuración, y sin correr el bootstrap (una segunda instancia purgaría el
`Temp\` de la que está viva). Es el modo que usa el PANEL para actualizar.
- **No se interrumpe una restauración en curso** (`Temp\` no vacío): sale con **75**
(`EX_TEMPFAIL`), que el PANEL traduce a "reintenta luego". Windows no tenía esta guarda y una
reinstalación a destiempo se llevaba por delante el respaldo que se estuviera restaurando,
dejándolo vetado y la base en `SINGLE_USER`.
- **Respaldo y reversión automática**: si el binario nuevo no arranca, se vuelve al anterior.
El respaldo solo se descarta tras confirmar que la versión nueva corre.
- **Rearranque garantizado en todos los modos.** La detención corría siempre, pero solo
`-Service` volvía a arrancar algo: actualizar con `desktop` o `none` mataba el agente y se iba
sin dejar señal.
- La espera a que el SO libere el `.exe` pasa de 5 s a 30 s con reintentos de la copia: con un
antivirus escaneando un onefile de ~270 MB, 5 s se quedaban cortos y la copia abortaba.
- Se distingue **"no es administrador"** de **"es administrador con el token filtrado por UAC"**,
que es lo que recibe una sesión de OpenSSH. Se veían idénticos y el remedio es el opuesto: el
segundo no se arregla cambiando de cuenta sino con `LocalAccountTokenFilterPolicy`.
### `--headless` ahora funciona en Windows
`_ensure_qt_platform()` salía de inmediato en `win32`, así que la bandera no hacía nada ahí. La
tarea ONSTART corre como SYSTEM en la sesión 0, sin escritorio interactivo, y arrancaba con el
plugin Qt `windows` intentando crear una ventana real. En Linux el mismo modo funcionaba porque
el unit de systemd fija `QT_QPA_PLATFORM=offscreen` por fuera, y esa asimetría escondió el
defecto. Ahora `--headless` fuerza `offscreen` en todas las plataformas y no se muestra ventana.
### Documentación
- `README.md` afirmaba que la app no puede correr como servicio de Windows y sugería NSSM, lo
que contradecía a `install.ps1` desde que existe. Corregido.
- `packaging/LEEME.txt` solo documentaba el camino manual para Windows; ahora incluye el
instalador, igual que ya hacía para Linux.
## [1.1.0] - 2026-07-29
### Distribución e instalación automatizada vía Gitea + PANEL

View File

@@ -472,7 +472,7 @@ R: Sí, especifica el nombre o IP del servidor remoto en Configuración → SQL
R: Sí, la aplicación usa RESTORE FILELISTONLY para detectar todos los archivos lógicos (MDF, LDF, y archivos adicionales). Solo mantiene el archivo principal de datos (.mdf) y de log (.ldf).
**P: ¿Puedo ejecutar esto como Windows Service?**
R: No, la aplicación está diseñada como "daemon de usuario" con interfaz gráfica. Para ejecutar como servicio, considera usar NSSM (Non-Sucking Service Manager) para envolver el ejecutable, pero perderás la UI.
R: Sí, con `install.ps1 -Service`: registra una tarea programada ONSTART que corre como SYSTEM, sin sesión iniciada y sin UI. **No** se usa NSSM ni ningún envoltorio descargado — el servidor destino no instala ni baja nada, y una tarea programada ya viene en el SO. Requiere PowerShell como Administrador. Ver [BUILD.md](BUILD.md) §6.
## 📄 Licencia

View File

@@ -11,12 +11,23 @@
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.
@@ -28,12 +39,13 @@
.EXAMPLE
.\install.ps1 -Service
.EXAMPLE
.\install.ps1 -Desktop -Prefix 'D:\CloudRestoreAS'
.\install.ps1 -UpdateInPlace -Prefix 'D:\CloudRestoreAS'
#>
[CmdletBinding()]
param(
[switch]$Service,
[switch]$Desktop,
[switch]$UpdateInPlace,
[string]$Prefix = 'C:\Aduanasoft\CloudRestoreAS',
[string]$PanelEnvFile = ''
)
@@ -42,17 +54,137 @@ $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 }
if ($Service -and $Desktop) {
throw 'Elige -Service o -Desktop, no ambos.'
$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 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.
#>
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 {
param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Get-AgentProcess) { return $true }
Start-Sleep -Seconds 2
}
return $false
}
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
}
}
}
$Mode = if ($Service) { 'service' } elseif ($Desktop) { 'desktop' } else { 'none' }
# --- Localizar el binario (mismas rutas candidatas que install.sh) -------------------
$candidates = @(
@@ -65,11 +197,29 @@ if (-not $src) {
}
# -Service crea una tarea como SYSTEM: requiere elevación.
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
#
# 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) {
throw 'Se requiere PowerShell como Administrador para -Service (tarea ONSTART como SYSTEM).'
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
@@ -79,48 +229,71 @@ 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
$dest = Join-Path $Prefix $BinName
# Si hay una instancia corriendo, el .exe queda bloqueado y Copy-Item falla. Se detiene
# la tarea y se esperan los procesos antes de reemplazar (caso actualización).
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Write-Step 'Deteniendo tarea programada existente'
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | ForEach-Object {
$_ | Stop-Process -Force -ErrorAction SilentlyContinue
}
# Espera acotada a que el SO libere el archivo; sin esto el Copy-Item puede fallar.
for ($i = 0; $i -lt 10; $i++) {
if (-not (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue)) { break }
Start-Sleep -Milliseconds 500
$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-Item -LiteralPath $src -Destination $dest -Force
Copy-Binary -From $src -To $dest
Write-Ok "Binario instalado en $dest"
# --- 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.
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
#
# 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
}
} 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 creado.'
Write-Ok 'config\.env presente.'
} else {
Write-Warn 'config\.env se creará en la primera ejecución.'
}
@@ -178,6 +351,9 @@ if ($PanelEnvFile) {
}
}
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 {
@@ -192,7 +368,8 @@ 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. QT_QPA_PLATFORM=offscreen porque SYSTEM no tiene sesión gráfica.
# 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
@@ -205,32 +382,87 @@ switch ($Mode) {
$action = New-ScheduledTaskAction -Execute $dest `
-Argument '--start-engine --headless' -WorkingDirectory $Prefix
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount `
$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 $principal -Settings $settings -Force | Out-Null
-Principal $taskPrincipal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
Write-Ok "Tarea '$TaskName' registrada y arrancada."
Write-Host " Estado : Get-ScheduledTask -TaskName $TaskName"
Write-Host " Logs : $Prefix\config\logs"
}
'desktop' {
Write-Host 'Modo escritorio: la app registra su autostart ONLOGON al iniciarse.'
Write-Host "Ejecuta '$dest' en tu sesión."
# 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.'
Write-Host "Ejecuta: `"$dest`" --start-engine --headless"
}
}
Write-Host ''
Write-Host "Siguiente paso: revisa $envPath (CLOUDRESTORE_PANEL_*) y reinicia."
if ($Mode -eq 'service') {
Write-Host " Tras editar: Stop-ScheduledTask -TaskName $TaskName; Start-ScheduledTask -TaskName $TaskName"
# --- 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) {
if (-not (Get-AgentProcess)) {
$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

View File

@@ -18,6 +18,29 @@ Cerrar la ventana (X) minimiza a la bandeja; la app sigue en ejecucion.
Para salir por completo: clic derecho en el icono de bandeja -> Salir,
o menu Archivo -> Salir.
-------------------------------------
Windows en SERVIDOR (24/7, sin sesion iniciada)
-------------------------------------
Lo normal es que el PANEL instale y actualice solo, desde Versiones CRAS. Esto es
el camino manual, y es lo mismo que el PANEL ejecuta por dentro.
Opcion A - servicio 24/7 (recomendado en servidor SQL):
.\install.ps1 -Service
(registra una tarea programada ONSTART que corre como SYSTEM, sin sesion
iniciada y sin ventana; ver estado: Get-ScheduledTask -TaskName CloudRestoreAS)
Requiere PowerShell como Administrador.
Opcion B - actualizar una instalacion que ya existe:
.\install.ps1 -UpdateInPlace
(reemplaza el binario conservando la tarea y la configuracion; no actua si hay
una restauracion en curso, y revierte solo si la version nueva no arranca)
Opcion C - escritorio Windows (con sesion):
.\install.ps1 -Desktop
No se usa NSSM ni ningun envoltorio de servicio: la tarea programada ya viene en el
SO, y en el servidor destino no se instala ni se descarga nada.
-------------------------------------
Linux en SERVIDOR SIN ESCRITORIO (headless)
-------------------------------------

View File

@@ -77,19 +77,33 @@ def _print_version() -> int:
def _ensure_qt_platform(headless: bool = False) -> str:
"""
Selecciona el plugin de plataforma Qt en Linux. En un servidor headless (sin
DISPLAY/WAYLAND_DISPLAY) o con --headless, usa 'offscreen' para que la app
arranque y el motor trabaje sin X; con display usa el default ('xcb'). No toca
nada si el usuario ya fijó QT_QPA_PLATFORM, ni en Windows/macOS.
Selecciona el plugin de plataforma Qt.
Devuelve la plataforma forzada ("offscreen") o "" si se deja el default.
`--headless` fuerza 'offscreen' en TODAS las plataformas. Antes se salía de inmediato en
Windows, así que la bandera no hacía nada ahí: la tarea programada ONSTART —que corre como
SYSTEM, en la sesión 0, sin escritorio interactivo— arrancaba con el plugin 'windows' e
intentaba crear una ventana real. Ese es el motivo de que el agente no levantara tras
instalarse en Windows mientras en Linux, donde el unit fija QT_QPA_PLATFORM=offscreen, sí.
El plugin va embebido en el binario de las dos plataformas (qoffscreen.dll / libqoffscreen.so).
La autodetección por DISPLAY/WAYLAND_DISPLAY sigue siendo solo de Linux: es donde su ausencia
significa "no hay servidor gráfico". En Windows y macOS no existen esas variables y tomarlas
como señal mandaría a offscreen a cualquiera que abra la app con doble clic.
No toca nada si el usuario ya fijó QT_QPA_PLATFORM: una elección explícita manda sobre todo
lo demás.
Devuelve la plataforma en uso ("offscreen") o "" si se deja el default.
"""
if sys.platform in ("win32", "darwin"):
return ""
if os.environ.get("QT_QPA_PLATFORM"):
return os.environ["QT_QPA_PLATFORM"]
if headless:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
return "offscreen"
if sys.platform in ("win32", "darwin"):
return ""
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if headless or not has_display:
if not has_display:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
return "offscreen"
return ""
@@ -206,11 +220,16 @@ def _run() -> int:
start_engine=start_engine,
panel_configured=panel_ok,
)
# La ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja.
window.show()
window.activateWindow()
window.raise_()
app_logger.info("Ventana principal mostrada")
# Con display, la ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja.
# En offscreen no hay a quién mostrarla, y pedirlo igual solo da trabajo al plugin y ruido en
# el log. El motor no depende de esto: arranca por su propio temporizador en MainWindow.
if qt_platform == "offscreen":
app_logger.info("Sin interfaz: la ventana no se muestra (motor headless)")
else:
window.show()
window.activateWindow()
window.raise_()
app_logger.info("Ventana principal mostrada")
exit_code = app.exec()
app_logger.info(f"Aplicación finalizada con código: {exit_code}")

View File

@@ -97,6 +97,68 @@ def test_instaladores_aceptan_panel_env_file():
assert "CLOUDRESTORE_PANEL_INSTANCE_KEY" in text
def test_ambos_instaladores_reservan_el_75_para_restauracion_en_curso():
"""
75 (EX_TEMPFAIL) es el contrato con el PANEL: significa "reintenta luego", no "falló la
instalación", y el panel lo traduce a un 409 amable. Con el código genérico, el operador
salía a investigar una avería inexistente mientras el respaldo que se estaba restaurando
quedaba vetado y la base en SINGLE_USER.
Windows no tenía esta guarda: reinstalar se llevaba por delante la restauración en curso.
"""
sh = (ROOT / "install.sh").read_text(encoding="utf-8")
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "exit 75" in sh
assert "EXIT_RESTORE_IN_PROGRESS = 75" in ps1
assert "exit $EXIT_RESTORE_IN_PROGRESS" in ps1
# La señal de "hay un job en vuelo" es la misma en ambos: Temp/ no vacío.
for text in (sh, ps1):
assert "Temp" in text
def test_install_ps1_acepta_update_in_place():
"""El PANEL actualiza con -UpdateInPlace; sin el parámetro, la actualización aborta."""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert "[switch]$UpdateInPlace" in ps1
# Y debe saltarse el bootstrap: una segunda instancia purga Temp/ de la que está viva.
assert "$Mode -ne 'update-in-place'" in ps1
def test_install_ps1_respalda_y_revierte():
"""
Reemplazar el binario de un servidor en producción sin red de seguridad significa que un
binario que no arranca deja el servidor sin restaurador y sin forma de recuperarlo salvo
entrando a mano. install.sh ya respaldaba y revertía; install.ps1 no.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
assert ".$BinName.prev" in ps1
assert "Wait-AgentAlive" in ps1
# El respaldo solo se descarta tras confirmar que la versión nueva corre.
assert "Remove-Item -LiteralPath $backup" in ps1
def test_el_arranque_automatico_de_windows_pide_headless():
"""
La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es
`--headless` lo que hace que el binario elija el plugin Qt 'offscreen'; sin esa bandera Qt
intenta el plugin 'windows' y el agente no levanta.
Se prueba el contrato COMPLETO —quien lanza y quien recibe— porque el defecto original fue
justamente que las dos mitades no coincidían: install.ps1 documentaba en un comentario que
fijaba QT_QPA_PLATFORM y no lo hacía, y runner.py ignoraba --headless en Windows.
"""
ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8")
runner_py = (ROOT / "runner.py").read_text(encoding="utf-8")
assert "-Argument '--start-engine --headless'" in ps1
# El binario tiene que honrar la bandera ANTES de mirar la plataforma; si el early-return de
# win32/darwin vuelve a quedar primero, --headless deja de hacer nada en Windows.
headless_at = runner_py.index('if headless:\n os.environ["QT_QPA_PLATFORM"]')
win32_at = runner_py.index('if sys.platform in ("win32", "darwin"):')
assert headless_at < win32_at
@pytest.mark.skipif(sys.platform == "win32", reason="usa bash y sha256sum")
def test_package_release_genera_manifiesto_consistente(tmp_path: Path):
"""

View File

@@ -0,0 +1,69 @@
"""
Selección del plugin de plataforma Qt.
Esto decide si el agente arranca o no en un servidor. El caso que motivó las pruebas: en
Windows, `--headless` no hacía nada —la función salía de inmediato en `win32`—, así que la
tarea programada ONSTART, que corre como SYSTEM en la sesión 0 y sin escritorio interactivo,
arrancaba con el plugin 'windows' e intentaba crear una ventana real. En Linux el mismo modo
funcionaba porque el unit de systemd fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría
escondió el defecto: la bandera parecía cubierta en las dos plataformas.
"""
import runner
def test_headless_fuerza_offscreen_en_windows(monkeypatch):
# La regresión: la tarea ONSTART pasa --headless y necesita que sirva de algo.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
assert runner._ensure_qt_platform(headless=True) == "offscreen"
assert runner.os.environ["QT_QPA_PLATFORM"] == "offscreen"
def test_headless_fuerza_offscreen_en_linux(monkeypatch):
monkeypatch.setattr(runner.sys, "platform", "linux")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.setenv("DISPLAY", ":0")
# Con --headless da igual que haya display: lo pidió el llamador.
assert runner._ensure_qt_platform(headless=True) == "offscreen"
def test_windows_sin_headless_conserva_el_plugin_nativo(monkeypatch):
# Quien abre la app con doble clic quiere su ventana. En Windows no existen DISPLAY ni
# WAYLAND_DISPLAY, así que tomar su ausencia como señal mandaría a offscreen a todos.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
assert runner._ensure_qt_platform(headless=False) == ""
assert "QT_QPA_PLATFORM" not in runner.os.environ
def test_linux_sin_display_cae_a_offscreen(monkeypatch):
monkeypatch.setattr(runner.sys, "platform", "linux")
monkeypatch.delenv("QT_QPA_PLATFORM", raising=False)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
assert runner._ensure_qt_platform(headless=False) == "offscreen"
def test_una_eleccion_explicita_manda_sobre_headless(monkeypatch):
# El instalador fija QT_QPA_PLATFORM para el bootstrap; un override del operador tiene que
# sobrevivir, o depurar un arranque en un servidor ajeno se vuelve imposible.
monkeypatch.setattr(runner.sys, "platform", "win32")
monkeypatch.setenv("QT_QPA_PLATFORM", "minimal")
assert runner._ensure_qt_platform(headless=True) == "minimal"
assert runner.os.environ["QT_QPA_PLATFORM"] == "minimal"
def test_la_bandera_headless_existe_en_el_parser():
# La tarea programada y el unit de systemd la pasan; si desaparece, argparse aborta el
# arranque con código 2 y el agente no levanta en ningún servidor.
args = runner.parse_args(["--start-engine", "--headless"])
assert args.headless is True
assert args.start_engine is True