fix/windows-actualizacion-silenciosa #26
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user