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:
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user