refactor(cras-install): una sola comparacion de rutas de Windows, y hacer visible el destino

La pregunta "son la misma ruta?" estaba resuelta tres veces con tres criterios:
normalizeWindowsPath recortaba comillas multiples, probeWindowsAgentProcess una
sola, y cras-verify ninguna. Coincidian en los casos reales, pero este modulo ya
lleva escrito lo que cuesta esa duplicacion — probeLinuxElevation e inspectLinux
tuvieron copias paralelas y las dos pantallas acabaron diciendo cosas distintas
del mismo servidor. Ahora es sameWindowsPath() + windowsAgentExe(), usadas en los
tres sitios.

Lo que NO se hace, y queda documentado en el codigo: comparar por prefijo.
C:\Aduanasoft\CloudRestoreAS es prefijo de cadena de
C:\Aduanasoft\CloudRestoreAS-win, y esa combinacion existe en produccion.

Pruebas del par peligroso en las dos direcciones. Comprobado que MUERDEN:
sustituyendo la igualdad por startsWith, falla la comparacion a nivel de carpeta.
Las de rutas completas de .exe no fallan, y eso tambien es informacion — son
estructuralmente inmunes porque el caracter que difiere llega antes del final, asi
que el riesgo vive solo en la comparacion de directorios.

Y el formulario dice a que carpeta va a instalar. Antes la ruta solo aparecia
dentro del aviso de "instalar limpio", que se pinta unicamente si el panel ya
conoce la version instalada: en un servidor con ruta personalizada y version
desconocida el operador no la veia hasta que el run fallaba. Se distingue "ruta
registrada" de "por omision" — no si la capturo el agente o una persona, porque
ambas viven en la misma columna y el panel no puede saberlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:10:07 -06:00
parent 3572aa974b
commit 9b05361a00
4 changed files with 151 additions and 19 deletions

View File

@@ -29,9 +29,11 @@ import {
probeInPlaceUpdate, probeInPlaceUpdate,
probeLinuxElevation, probeLinuxElevation,
alignWindowsTask, alignWindowsTask,
normalizeWindowsPath,
probeWindowsAgentProcess, probeWindowsAgentProcess,
probeWindowsElevation, probeWindowsElevation,
probeWindowsTask, probeWindowsTask,
sameWindowsPath,
psEncoded, psEncoded,
shQuote, shQuote,
verifyWindowsDeployment, 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 * 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. * 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', () => { describe('sondas de ruta en Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
function psFake(stdout: string) { function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout })); 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 () => { it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
expect(await probeWindowsTask(sftp as never)).toEqual({ expect(await probeWindowsTask(sftp as never)).toEqual({
@@ -533,6 +595,20 @@ describe('alignWindowsTask', () => {
expect(commands).toHaveLength(0); 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 () => { it('si no se puede corregir, falla nombrando las dos rutas', async () => {
const { sftp } = psFake('error|Acceso denegado'); const { sftp } = psFake('error|Acceso denegado');
await expect( 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 { export interface WindowsProcessProbe {
running: boolean; running: boolean;
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */ /** 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 joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean); const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`.toLowerCase(); const wanted = windowsAgentExe(prefix);
return { return {
running: true, running: true,
paths, 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. * 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 targetPath: string
): Promise<boolean> { ): Promise<boolean> {
if (!taskExecute) return false; if (!taskExecute) return false;
const wanted = `${targetPath.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; const wanted = windowsAgentExe(targetPath);
if (normalizeWindowsPath(taskExecute) === normalizeWindowsPath(wanted)) return false; if (sameWindowsPath(taskExecute, wanted)) return false;
// `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla // `$argumentos` y no `$args`: `$args` es una variable automática de PowerShell y asignarla
// dentro de un bloque de script es un error. // dentro de un bloque de script es un error.
@@ -1279,7 +1309,7 @@ export async function restartWindowsAgent(
prefix: string, prefix: string,
timeoutMs = 60_000 timeoutMs = 60_000
): Promise<WindowsProcessProbe> { ): Promise<WindowsProcessProbe> {
const exe = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`; const exe = windowsAgentExe(prefix);
await execRemote( await execRemote(
sftp, sftp,
psEncoded( psEncoded(

View File

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

View File

@@ -5,7 +5,7 @@
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import AppShell from '$lib/components/AppShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import Spinner from '$lib/components/Spinner.svelte'; import Spinner from '$lib/components/Spinner.svelte';
import { effectiveInstallPath, platformLabel } from '$lib/cras-version'; import { effectiveInstallPath, isDefaultInstallPath, platformLabel } from '$lib/cras-version';
import { import {
INSTALL_STEP_LABEL, INSTALL_STEP_LABEL,
currentPhaseLabel, currentPhaseLabel,
@@ -1346,6 +1346,29 @@
{/if} {/if}
</div> </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> <div>
<label for="autostart" class="block text-sm font-medium text-gray-700"> <label for="autostart" class="block text-sm font-medium text-gray-700">
Arranque automático Arranque automático