fix/windows-actualizacion-silenciosa #26

Merged
acazares merged 6 commits from fix/windows-actualizacion-silenciosa into development 2026-07-31 17:57:11 +00:00
4 changed files with 151 additions and 19 deletions
Showing only changes of commit 9b05361a00 - Show all commits

View File

@@ -29,9 +29,11 @@ import {
probeInPlaceUpdate,
probeLinuxElevation,
alignWindowsTask,
normalizeWindowsPath,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
sameWindowsPath,
psEncoded,
shQuote,
verifyWindowsDeployment,
@@ -429,11 +431,71 @@ describe('probeWindowsElevation', () => {
* que lo único que se actualiza se identifica por RUTA. En cuanto las dos no coincidían se
* reemplazaba un binario y se arrancaba otro, y ni el instalador ni el panel se enteraban.
*/
/**
* Rutas de instalación personalizadas.
*
* `C:\Aduanasoft\CloudRestoreAS-win` es el peor caso posible y existe en producción: la ruta por
* omisión `C:\Aduanasoft\CloudRestoreAS` es **prefijo de cadena** de ella. Cualquier comparación
* hecha con `startsWith` daría por iguales dos instalaciones distintas, y el resultado sería
* exactamente el fallo que se estuvo persiguiendo: actualizar una carpeta y arrancar la otra.
*
* Las pruebas de más abajo usaban nombres sin relación entre sí (`C:\Declarada` contra `C:\Otra`),
* así que un `startsWith` mal puesto las pasaba todas.
*/
describe('comparación de rutas de Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
it('NO confunde la ruta por omisión con una que la tiene como prefijo', () => {
expect(sameWindowsPath(DEFECTO, PERSONALIZADA)).toBe(false);
// Y en el otro sentido: da igual cuál sea la instalada.
expect(sameWindowsPath(PERSONALIZADA, DEFECTO)).toBe(false);
expect(
sameWindowsPath(`${DEFECTO}\\CloudRestoreAS.exe`, `${PERSONALIZADA}\\CloudRestoreAS.exe`)
).toBe(false);
});
it('normaliza comillas, barras finales y mayúsculas', () => {
// La acción de una tarea guarda la ruta entrecomillada; una ruta capturada a mano suele
// traer barra final; y NTFS no distingue caso.
expect(sameWindowsPath(`"${PERSONALIZADA}"`, PERSONALIZADA)).toBe(true);
expect(sameWindowsPath(`${PERSONALIZADA}\\`, PERSONALIZADA)).toBe(true);
expect(sameWindowsPath(PERSONALIZADA.toUpperCase(), PERSONALIZADA)).toBe(true);
expect(normalizeWindowsPath(' ""C:\\X\\"" ')).toBe('c:\\x');
});
it('una ruta vacía no es igual a nada, ni a otra vacía', () => {
// Si lo fuera, un `execute` sin leer pasaría por "ya está alineada" y no se corregiría nada.
expect(sameWindowsPath('', '')).toBe(false);
expect(sameWindowsPath('', PERSONALIZADA)).toBe(false);
});
});
describe('sondas de ruta en Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
it('el proceso de la ruta por omisión no cuenta como el de la personalizada', async () => {
const { sftp } = psFake(`corriendo|${DEFECTO}\\CloudRestoreAS.exe`);
const r = await probeWindowsAgentProcess(sftp as never, PERSONALIZADA);
expect(r.running).toBe(true);
expect(r.fromPrefix).toBe(false);
});
it('ni al contrario: el de la personalizada no cuenta como el de la de omisión', async () => {
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
expect((await probeWindowsAgentProcess(sftp as never, DEFECTO)).fromPrefix).toBe(false);
});
it('y el de la personalizada sí cuenta cuando esa es la instalación', async () => {
const { sftp } = psFake(`corriendo|${PERSONALIZADA}\\CloudRestoreAS.exe`);
expect((await probeWindowsAgentProcess(sftp as never, PERSONALIZADA)).fromPrefix).toBe(true);
});
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
expect(await probeWindowsTask(sftp as never)).toEqual({
@@ -533,6 +595,20 @@ describe('alignWindowsTask', () => {
expect(commands).toHaveLength(0);
});
it('reapunta cuando la tarea usa la ruta por omisión y la instalación es la personalizada', async () => {
// El servidor real: instalado en ...-win, tarea apuntando a la carpeta por omisión. Con una
// comparación por prefijo esto se daría por alineado y no se corregiría nada.
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
'C:\\Aduanasoft\\CloudRestoreAS-win'
);
expect(cambio).toBe(true);
expect(commands).toHaveLength(1);
});
it('si no se puede corregir, falla nombrando las dos rutas', async () => {
const { sftp } = psFake('error|Acceso denegado');
await expect(

View File

@@ -1100,6 +1100,45 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
}
}
/**
* Comparación de rutas de Windows. Una sola implementación, a propósito.
*
* La pregunta "¿son la misma ruta?" estaba resuelta en tres sitios con tres criterios ligeramente
* distintos (una recortaba comillas múltiples, otra una sola, la tercera ninguna). Coincidían en los
* casos reales, pero este módulo ya lleva escrito lo que cuesta esa duplicación: `probeLinuxElevation`
* e `inspectLinux` tenían copias paralelas y las dos pantallas acabaron diciendo cosas distintas del
* mismo servidor.
*
* Lo que hay que normalizar y por qué:
* - **Comillas**: la acción de una tarea programada guarda la ruta entrecomillada.
* - **Barras finales**: una ruta capturada a mano suele traerlas.
* - **Mayúsculas**: NTFS no las distingue, así que comparar sensible al caso reporta dos rutas
* distintas donde el sistema de archivos ve una sola.
*
* Lo que NO se hace es comparar por prefijo. `C:\Aduanasoft\CloudRestoreAS` es prefijo de cadena de
* `C:\Aduanasoft\CloudRestoreAS-win`, así que un `startsWith` daría por iguales dos instalaciones
* distintas — y esa combinación existe en producción.
*/
export function normalizeWindowsPath(value: string): string {
return String(value ?? '')
.trim()
.replace(/^"+|"+$/g, '')
.replace(/\\+$/, '')
.toLowerCase();
}
/** ¿Las dos rutas apuntan al mismo archivo o carpeta? Igualdad exacta tras normalizar. */
export function sameWindowsPath(a: string, b: string): boolean {
const na = normalizeWindowsPath(a);
const nb = normalizeWindowsPath(b);
return na !== '' && na === nb;
}
/** Ruta del ejecutable del agente dentro de una carpeta de instalación. */
export function windowsAgentExe(prefix: string): string {
return `${String(prefix ?? '').trim().replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
}
export interface WindowsProcessProbe {
running: boolean;
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
@@ -1139,11 +1178,11 @@ export async function probeWindowsAgentProcess(
}
const joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase();
const wanted = windowsAgentExe(prefix);
return {
running: true,
paths,
fromPrefix: paths.some((p) => p.replace(/^"|"$/g, '').toLowerCase() === wanted)
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
};
}
@@ -1190,15 +1229,6 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
};
}
/**
* Normaliza una ruta de Windows para compararla: NTFS no distingue mayúsculas y la acción de una
* tarea guarda la ruta entrecomillada. Comparar las cadenas en crudo da falsos negativos, y un
* falso negativo aquí significa reescribir una tarea que ya estaba bien.
*/
function normalizeWindowsPath(value: string): string {
return value.trim().replace(/^"+|"+$/g, '').replace(/\\+$/, '').toLowerCase();
}
/**
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
*
@@ -1218,8 +1248,8 @@ export async function alignWindowsTask(
targetPath: string
): Promise<boolean> {
if (!taskExecute) return false;
const wanted = `${targetPath.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
if (normalizeWindowsPath(taskExecute) === normalizeWindowsPath(wanted)) return false;
const wanted = windowsAgentExe(targetPath);
if (sameWindowsPath(taskExecute, wanted)) return false;
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
// dentro de un bloque de script es un error.
@@ -1279,7 +1309,7 @@ export async function restartWindowsAgent(
prefix: string,
timeoutMs = 60_000
): Promise<WindowsProcessProbe> {
const exe = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
const exe = windowsAgentExe(prefix);
await execRemote(
sftp,
psEncoded(

View File

@@ -24,8 +24,10 @@ import {
probeWindowsTask,
psEncoded,
readUnitProps,
sameWindowsPath,
shQuote,
unitUserOrRoot
unitUserOrRoot,
windowsAgentExe
} from './cras-install';
import { listCrasTargetInventory } from './cras-releases';
import { DEFAULT_INSTALL_PATHS, effectiveInstallPath, type CrasPlatform } from '$lib/cras-version';
@@ -827,9 +829,10 @@ async function inspectWindows(
// apunta a otra carpeta hace que actualizar no surta efecto sin que nada falle: se reemplaza el
// binario de una y se arranca el de otra. Aquí se ve, en vez de descubrirse después.
const taskProbe = await probeWindowsTask(sftp);
const esperado = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
const taskAlineada =
!taskProbe.execute || taskProbe.execute.toLowerCase() === esperado.toLowerCase();
const esperado = windowsAgentExe(prefix);
// Misma comparación que usa el instalador. Tenerla duplicada aquí con otro criterio dejaba a las
// dos pantallas capaces de discrepar sobre el mismo servidor.
const taskAlineada = !taskProbe.execute || sameWindowsPath(taskProbe.execute, esperado);
if (taskProbe.exists) {
checks.push(
check(

View File

@@ -5,7 +5,7 @@
import type { SubmitFunction } from '@sveltejs/kit';
import AppShell from '$lib/components/AppShell.svelte';
import Spinner from '$lib/components/Spinner.svelte';
import { effectiveInstallPath, platformLabel } from '$lib/cras-version';
import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version';
import {
INSTALL_STEP_LABEL,
currentPhaseLabel,
@@ -1346,6 +1346,29 @@
{/if}
</div>
<!-- Ruta de destino, siempre visible.
Antes solo aparecía dentro del aviso de "instalar limpio", que se pinta únicamente
si el panel ya conoce la versión instalada. En un servidor con ruta personalizada
y versión desconocida —el caso que costó dos rondas de diagnóstico— el operador no
veía a qué carpeta iba a instalar hasta que el run fallaba. -->
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs">
<p class="text-slate-700">
Se instalará en <code class="font-mono text-slate-900">{targetInstallPath}</code>
</p>
<p class="mt-0.5 text-slate-500">
{#if installTarget?.reported_install_path}
Ruta registrada para {installTarget.name}. Se cambia con el lápiz que está
junto al nombre del servidor, en la tabla.
{:else if installTarget?.platform && isDefaultInstallPath(targetInstallPath, installTarget.platform)}
Ruta por omisión: este servidor no tiene ninguna registrada. Si el agente
vive en otra carpeta, captúrala primero con el lápiz de la tabla instalar
aquí crearía una segunda instalación.
{:else}
Sin ruta registrada para este servidor.
{/if}
</p>
</div>
<div>
<label for="autostart" class="block text-sm font-medium text-gray-700">
Arranque automático