Compare commits
2 Commits
c8133a5108
...
0a62b7d0aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a62b7d0aa | |||
| c2afa52d6f |
31
BUILD.md
31
BUILD.md
@@ -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.
|
||||
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
324
install.ps1
324
install.ps1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
-------------------------------------
|
||||
|
||||
45
runner.py
45
runner.py
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
69
tests/test_runner_qt_platform.py
Normal file
69
tests/test_runner_qt_platform.py
Normal 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
|
||||
Reference in New Issue
Block a user