fix(cras-install): realinear la tarea de Windows desde el panel, y decir por que fallo

La actualizacion ya fallaba ruidosamente en vez de mentir, pero el arreglo no
podia llegar al servidor: el install.ps1 que se ejecuta viaja DENTRO del zip, y
el artefacto 1.1.3 se construyo antes de que el instalador aprendiera a
realinear la tarea. Verificado sobre el zip: trae -UpdateInPlace y cero
Sync-AgentTaskPath. Un instalador ya publicado no se arregla hacia atras.

- alignWindowsTask realinea la accion de la tarea por SSH tras instalar el
  binario, conservando disparador, principal, ajustes y argumentos, y asienta la
  ruta ANTERIOR y la nueva para que el cambio sea reversible si la ruta declarada
  estuviera mal. Si no se puede corregir, aborta con 409 nombrando ambas: seguir
  significaria arrancar a sabiendas el binario viejo. Funciona con cualquier
  artefacto ya distribuido.
- restartWindowsAgent rearranca y espera a que corra el binario del prefijo, no
  cualquier proceso con ese nombre. Vive en cras-install y no en
  cras-agent-control porque este es el modulo de abajo; al reves habria un ciclo.

Y el diagnostico deja de ser una tarea para el operador. El error de sello
ausente decia "revisa a que binario apunta el arranque automatico" cuando el
panel YA lo sabe, y la bitacora del run donde si estaba no la encontraba nadie
("no se donde verlo"). Ahora se sondea tarea y proceso ANTES de evaluar el sello
y el mensaje nombra a que apunta la tarea, desde donde corre el proceso, y la
cola de CloudRestoreAS-crash.log — que runner.py escribe precisamente para esto
y que nunca se leia. El banner de error apunta al run por numero.

startOnWindows deja de esperar por nombre y usa la misma sonda de ruta, para que
Verificar no pueda contradecir al instalador.

PowerShell generado validado con PowerShell real: los seis scripts parsean, y el
de realineacion ejercitado contra una tarea simulada reapunta la accion Exec,
conserva los argumentos y respeta las acciones que no son Exec.

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

View File

@@ -16,6 +16,7 @@ import { getRestoreTargetSsh } from './controldesk-pg';
import {
execRemote,
probeLinuxElevation,
probeWindowsAgentProcess,
probeWindowsElevation,
psEncoded,
shQuote,
@@ -23,7 +24,11 @@ import {
} from './cras-install';
import { probeRemoteSystem } from './cras-verify';
import { listCrasTargetInventory } from './cras-releases';
import { effectiveInstallPath, DEFAULT_INSTALL_PATHS } from '$lib/cras-version';
import {
effectiveInstallPath,
DEFAULT_INSTALL_PATHS,
type CrasPlatform
} from '$lib/cras-version';
import type { ApiErrorStatus } from './api-error';
import { logger } from './logger';
@@ -65,7 +70,7 @@ async function waitAlive(check: () => Promise<boolean>): Promise<boolean> {
}
}
async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
async function startOnWindows(sftp: SftpClient, prefix: string): Promise<StartAgentOutcome> {
const privileged = await probeWindowsElevation(sftp);
if (privileged.elevation !== 'admin') {
throw new AgentControlError(
@@ -96,38 +101,51 @@ async function startOnWindows(sftp: SftpClient): Promise<StartAgentOutcome> {
);
}
// Se espera con la MISMA sonda que usa el instalador, que mira la ruta del ejecutable y no solo
// el nombre. Con la comprobación por nombre, un agente viejo corriendo desde otra carpeta hacía
// pasar por bueno un arranque que no ocurrió.
let ultima = await probeWindowsAgentProcess(sftp, prefix);
const alive = await waitAlive(async () => {
const proc = await execRemote(
sftp,
psEncoded(
"if (Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue) " +
'{"si"} else {"no"}'
)
);
return proc.stdout.trim() === 'si';
ultima = await probeWindowsAgentProcess(sftp, prefix);
// `paths` vacío con el proceso vivo es "no pude ver desde dónde corre" (un proceso de SYSTEM
// no expone su ruta sin elevación), y eso se acepta: no se afirma que sea de otra carpeta.
return ultima.fromPrefix || (ultima.running && ultima.paths.length === 0);
});
return alive
? { ok: true, detail: 'Tarea CloudRestoreAS arrancada y proceso en ejecución.' }
: {
ok: false,
detail:
'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
'config\\logs en el servidor: el agente está fallando al iniciar.'
};
if (alive) {
return {
ok: true,
detail:
'Tarea CloudRestoreAS arrancada y proceso en ejecución' +
(ultima.paths.length ? ` desde ${ultima.paths.join(', ')}.` : '.')
};
}
return {
ok: false,
detail: ultima.running
? `Hay un agente corriendo desde ${ultima.paths.join(', ')}, no desde ${prefix}: el ` +
'arranque automático apunta a otra instalación.'
: 'Se pidió el arranque de la tarea, pero el proceso no apareció. Revisa ' +
`${prefix}\\config\\logs en el servidor: el agente está fallando al iniciar.`
};
}
/**
* Ruta donde vive el agente, para poder reconocer su proceso.
*
* El ancla `^` del patrón no es opcional: sin ella, el `sh -c` que corre el propio `pgrep` lleva
* la ruta en su línea de comandos y haría match consigo mismo, reportando vivo un agente que
* nunca arrancó. Es el mismo motivo por el que install.sh ancla su patrón.
* Hace falta en las dos plataformas y por el mismo motivo: sin ella solo se puede preguntar por el
* NOMBRE del proceso, que no distingue el agente que nos interesa de otro corriendo desde otra
* carpeta. En Linux, además, el ancla `^` del patrón de `pgrep` no es opcional: sin ella el `sh -c`
* que corre el propio pgrep lleva la ruta en su línea de comandos y haría match consigo mismo,
* reportando vivo un agente que nunca arrancó.
*/
async function resolveLinuxPrefix(restoreTargetId: number): Promise<string> {
async function resolvePrefix(restoreTargetId: number, platform: CrasPlatform): Promise<string> {
const inventory = await listCrasTargetInventory();
const row = inventory.find((t) => t.restore_target_id === restoreTargetId);
return effectiveInstallPath(row?.reported_install_path ?? null, 'linux') ?? DEFAULT_INSTALL_PATHS.linux;
return (
effectiveInstallPath(row?.reported_install_path ?? null, platform) ??
DEFAULT_INSTALL_PATHS[platform]
);
}
async function startOnLinux(
@@ -203,11 +221,11 @@ export async function startCrasAgent(restoreTargetId: number): Promise<StartAgen
const system = await probeRemoteSystem(sftp);
if (system.verdict === 'windows') {
return await startOnWindows(sftp);
return await startOnWindows(sftp, await resolvePrefix(restoreTargetId, 'windows'));
}
if (system.verdict === 'linux') {
const privileged = await probeLinuxElevation(sftp, target.ssh_password);
const prefix = await resolveLinuxPrefix(restoreTargetId);
const prefix = await resolvePrefix(restoreTargetId, 'linux');
return await startOnLinux(sftp, privileged, prefix);
}
throw new AgentControlError(

View File

@@ -28,6 +28,7 @@ import {
execRemote,
probeInPlaceUpdate,
probeLinuxElevation,
alignWindowsTask,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
@@ -475,6 +476,75 @@ describe('sondas de ruta en Windows', () => {
});
});
/**
* Realineación de la tarea programada DESDE EL PANEL.
*
* Vive aquí y no solo en install.ps1 porque un instalador ya publicado no se arregla hacia atrás:
* el script que se ejecuta en el servidor viaja DENTRO del artefacto, y el de 1.1.3 salió antes de
* que install.ps1 aprendiera a realinear la tarea. Sin esto, actualizar a 1.1.3 reemplaza el
* binario de una carpeta y `Start-ScheduledTask` levanta el de otra.
*/
describe('alignWindowsTask', () => {
const DESTINO = 'C:\\Aduanasoft\\CloudRestoreAS-win';
const ESPERADO = `${DESTINO}\\CloudRestoreAS.exe`;
function psFake(stdout: string) {
return fakeSftp(() => ({ code: 0, stdout }));
}
it('reapunta una tarea que ejecuta otro binario', async () => {
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
DESTINO
);
expect(cambio).toBe(true);
const script = Buffer.from(
commands[0].split('-EncodedCommand ')[1] ?? '',
'base64'
).toString('utf16le');
expect(script).toContain(ESPERADO);
expect(script).toContain('Set-ScheduledTask');
// Se conservan los argumentos: reregistrar desde cero perdería ajustes del operador.
expect(script).toContain('Arguments');
// `$args` es una variable automática de PowerShell; asignarla sería un error en ejecución.
expect(script).not.toMatch(/\$args\s*=/);
});
it('no toca una tarea que ya apunta bien', async () => {
const { sftp, commands } = psFake('ok');
expect(await alignWindowsTask(sftp as never, 1, ESPERADO, DESTINO)).toBe(false);
expect(commands).toHaveLength(0);
});
it('ignora comillas y mayúsculas al comparar', async () => {
// La acción de la tarea guarda la ruta entrecomillada y NTFS no distingue caso: comparar en
// crudo reescribiría tareas correctas en cada actualización.
const { sftp, commands } = psFake('ok');
const cambio = await alignWindowsTask(
sftp as never,
1,
`"${ESPERADO.toLowerCase()}"`,
DESTINO
);
expect(cambio).toBe(false);
expect(commands).toHaveLength(0);
});
it('si no se puede corregir, falla nombrando las dos rutas', async () => {
const { sftp } = psFake('error|Acceso denegado');
await expect(
alignWindowsTask(sftp as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
).rejects.toThrow(/D:\\Vieja\\CloudRestoreAS\.exe/);
const { sftp: sftp2 } = psFake('error|Acceso denegado');
await expect(
alignWindowsTask(sftp2 as never, 1, 'D:\\Vieja\\CloudRestoreAS.exe', DESTINO)
).rejects.toThrow(new RegExp(ESPERADO.replace(/\\/g, '\\\\')));
});
});
/**
* Verificación del despliegue en Windows.
*
@@ -497,16 +567,26 @@ describe('verifyWindowsDeployment', () => {
running: boolean;
/** Desde dónde corre. Por omisión, el propio prefijo. */
procPath?: string;
/** Ruta que la tarea tiene registrada en su acción. */
taskExecute?: string;
crashLog?: string;
}) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
if (script.includes('crash.log')) return { code: 0, stdout: opts.crashLog ?? '' };
if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp };
if (script.includes('Get-Process')) {
if (!opts.running) return { code: 0, stdout: 'detenido' };
const ruta = opts.procPath ?? `${PREFIJO}\\CloudRestoreAS.exe`;
return { code: 0, stdout: `corriendo|${ruta}` };
}
// La sonda completa de la tarea se distingue de la consulta de estado por que pregunta
// también por el principal.
if (script.includes('Principal')) {
const exec = opts.taskExecute ?? `${PREFIJO}\\CloudRestoreAS.exe`;
return { code: 0, stdout: `si|${exec}|SYSTEM` };
}
if (script.includes('Get-ScheduledTask')) return { code: 0, stdout: opts.taskState };
return { code: 1, stdout: '' };
});
@@ -553,6 +633,64 @@ describe('verifyWindowsDeployment', () => {
).rejects.toThrow(/no escribió config\\\.version/);
});
/**
* Corre la verificación esperando que falle y devuelve el mensaje. Lanza si NO falla, para que
* una comprobación que dejara de detectar el problema no pase como verde con mensaje vacío.
*/
async function mensajeDeFallo(
...args: Parameters<typeof verifyWindowsDeployment>
): Promise<string> {
try {
await verifyWindowsDeployment(...args);
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
throw new Error('se esperaba que verifyWindowsDeployment fallara, y no falló');
}
it('el error del sello trae el diagnóstico hecho, no una tarea que investigar', async () => {
// El operador lee el mensaje, no la bitácora del run ("no sé dónde verlo"). El panel ya
// tiene a mano a qué apunta la tarea, desde dónde corre el proceso y el crash log: mandarlo
// a averiguarlo era pedirle algo que aquí ya se sabía.
const { sftp } = fakeWindows({
stamp: '',
taskState: 'Running',
running: true,
taskExecute: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
procPath: 'C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe',
crashLog: 'ImportError: no se pudo cargar el driver ODBC'
});
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
expect(mensaje).toContain('C:\\Aduanasoft\\CloudRestoreAS\\CloudRestoreAS.exe');
expect(mensaje).toMatch(/arranque automático ejecuta/);
expect(mensaje).toMatch(/hay un proceso corriendo desde/);
expect(mensaje).toContain('ImportError');
});
it('sin crash log el mensaje no se rompe', async () => {
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
expect(mensaje).toMatch(/no hay ningún proceso CloudRestoreAS corriendo/);
expect(mensaje).not.toContain('Último crash');
});
it('INSTALANDO limpio, un sello ausente sigue siendo aceptable', async () => {
// Ahí sí es legítimo: el bootstrap puede no haber alcanzado a escribirlo todavía.
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: true });

View File

@@ -1190,6 +1190,145 @@ 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.
*
* Esto lo hace el PANEL y no solo el instalador porque un instalador ya publicado no se puede
* arreglar hacia atrás: `install.ps1` aprendió a realinear la tarea después de que saliera 1.1.3,
* y el script que se ejecuta en el servidor es el que viaja DENTRO del artefacto. Haciéndolo desde
* aquí funciona con cualquier versión ya distribuida.
*
* Devuelve true si tuvo que cambiar algo. Reapunta solo la acción y conserva disparador, principal,
* ajustes y argumentos: volver a registrar la tarea desde cero perdería los ajustes que el operador
* le haya hecho.
*/
export async function alignWindowsTask(
sftp: SftpClient,
runId: number,
taskExecute: string,
targetPath: string
): Promise<boolean> {
if (!taskExecute) return false;
const wanted = `${targetPath.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
if (normalizeWindowsPath(taskExecute) === normalizeWindowsPath(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.
const result = await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
"if (-not $t) { Write-Output 'sin-tarea'; exit 0 }; " +
'$acciones = @(); ' +
'foreach ($a in @($t.Actions)) { ' +
" $pe = $a.PSObject.Properties['Execute']; " +
' if (-not $pe) { $acciones += $a; continue }; ' +
" $pa = $a.PSObject.Properties['Arguments']; " +
" $argumentos = if ($pa -and $pa.Value) { [string]$pa.Value } else { '' }; " +
' if ($argumentos) { $acciones += New-ScheduledTaskAction ' +
`-Execute '${wanted}' -Argument $argumentos -WorkingDirectory '${targetPath}' } ` +
'else { $acciones += New-ScheduledTaskAction ' +
`-Execute '${wanted}' -WorkingDirectory '${targetPath}' } }; ` +
"try { Set-ScheduledTask -TaskName 'CloudRestoreAS' -Action $acciones " +
"-ErrorAction Stop | Out-Null; Write-Output 'ok' } " +
"catch { Write-Output ('error|' + $_.Exception.Message) }"
)
);
const out = result.stdout.trim();
if (out === 'ok') {
// Se asienta la ruta ANTERIOR además de la nueva: si el operador declaró mal la ruta de
// instalación, este es el dato con el que se deshace el cambio a mano.
await appendInstallStep(
runId,
'realinear-arranque',
true,
`la tarea ejecutaba ${taskExecute}; ahora ejecuta ${wanted}`
);
return true;
}
// No se sigue adelante: arrancar a sabiendas el binario de otra carpeta es justo el fallo
// silencioso que se está persiguiendo.
throw new InstallError(
409,
`La tarea programada de ${targetPath} ejecuta ${taskExecute}, no el binario que se acaba de ` +
`instalar (${wanted}), y no se pudo corregir` +
(out.startsWith('error|') ? `: ${truncate(out.slice(6))}` : ' (no hay tarea registrada)') +
'. Sin eso, el arranque automático seguiría levantando la versión anterior.'
);
}
/**
* Reinicia el agente en Windows y espera a que corra el binario de `prefix`.
*
* Vive en este módulo y no en `cras-agent-control` porque este es el de más abajo: al revés habría
* una dependencia circular. `startCrasAgent` lo reutiliza en lugar de tener su propia copia.
*/
export async function restartWindowsAgent(
sftp: SftpClient,
prefix: string,
timeoutMs = 60_000
): Promise<WindowsProcessProbe> {
const exe = `${prefix.replace(/\\+$/, '')}\\CloudRestoreAS.exe`;
await execRemote(
sftp,
psEncoded(
"$t = Get-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue; " +
"if ($t) { Stop-ScheduledTask -TaskName 'CloudRestoreAS' -ErrorAction SilentlyContinue }; " +
"Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | " +
'Stop-Process -Force -ErrorAction SilentlyContinue; ' +
'Start-Sleep -Seconds 3; ' +
// Si hay tarea se arranca por ella (respeta su principal y su entorno); si no, el
// proceso suelto. `--headless` hace que el binario elija el plugin Qt offscreen.
"if ($t) { Start-ScheduledTask -TaskName 'CloudRestoreAS' } " +
`else { Start-Process -FilePath '${exe}' ` +
`-ArgumentList '--start-engine','--headless' -WorkingDirectory '${prefix}' ` +
'-WindowStyle Hidden }'
),
INSTALL_EXEC_TIMEOUT_MS
);
const deadline = Date.now() + timeoutMs;
let probe = await probeWindowsAgentProcess(sftp, prefix);
while (Date.now() < deadline) {
// `paths` vacío con el proceso vivo significa "no pude ver desde dónde corre" (un proceso de
// SYSTEM no expone su ruta sin elevación): se acepta, no se insiste hasta agotar el margen.
if (probe.fromPrefix || (probe.running && probe.paths.length === 0)) return probe;
await new Promise((resolve) => setTimeout(resolve, 2000));
probe = await probeWindowsAgentProcess(sftp, prefix);
}
return probe;
}
/**
* Cola del crash log que el agente escribe junto a su ejecutable cuando no consigue arrancar.
*
* `runner.py` lo escribe precisamente para que un arranque fallido sea visible
* (`_crash_log_targets()`), y hasta ahora nadie lo leía nunca: el operador recibía "no arrancó" en
* lugar de "no arrancó porque X". Devuelve cadena vacía si no existe.
*/
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
const result = await execRemote(
sftp,
psEncoded(
`$p = '${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log'; ` +
'if (Test-Path -LiteralPath $p) { ' +
'(Get-Content -LiteralPath $p -Tail 20) -join [Environment]::NewLine }'
)
);
return result.stdout.trim();
}
async function installWindows(
sftp: SftpClient,
runId: number,
@@ -1417,6 +1556,22 @@ async function installWindows(
);
}
// El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático
// apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
// actualización termina en verde sin haber cambiado nada.
if (await alignWindowsTask(sftp, runId, task.execute, targetPath)) {
const proc = await restartWindowsAgent(sftp, targetPath);
await appendInstallStep(
runId,
'rearrancar-agente',
proc.running,
proc.running
? `corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
: 'el agente no volvió a arrancar tras realinear la tarea'
);
}
await verifyWindowsDeployment(
sftp, runId, release, autostart, targetPath, inPlaceUpdate, request.mode === 'update'
);
@@ -1487,12 +1642,32 @@ export async function verifyWindowsDeployment(
// no es el binario que acabamos de instalar. La tolerancia se queda solo en instalación nueva,
// que es donde sí es legítima.
if (!deployed && isUpdate) {
// El mensaje trae el diagnóstico ya hecho. El panel tiene a mano a qué apunta la tarea,
// desde dónde corre el proceso y el crash log del agente; mandar al operador a "revisar el
// arranque automático" le pedía averiguar algo que aquí ya se sabe — y la bitácora del run,
// donde sí estaba, no la encuentra nadie.
const [task, proc, crash] = await Promise.all([
probeWindowsTask(sftp),
probeWindowsAgentProcess(sftp, prefix),
readWindowsCrashLog(sftp, prefix)
]);
const pistas = [
task.exists
? `el arranque automático ejecuta ${task.execute || '(sin acción)'}`
: 'no hay tarea CloudRestoreAS registrada, así que nadie levanta el agente',
proc.running
? `hay un proceso corriendo desde ${proc.paths.join(', ') || '(ruta no legible)'}`
: 'no hay ningún proceso CloudRestoreAS corriendo'
];
throw new InstallError(
502,
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
'Revisa a qué binario apunta el arranque automático del servidor y los logs en ' +
`${prefix}\\config\\logs.`
`${pistas.join('; ')}.` +
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
` Logs en ${prefix}\\config\\logs.`
);
}
await appendInstallStep(

View File

@@ -463,7 +463,18 @@
{#if showFormBanner && form?.error}
<div class="flex items-start gap-2 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
<span class="material-icons-outlined text-base">error_outline</span>
<p class="min-w-0 flex-1">{form.error}</p>
<div class="min-w-0 flex-1">
<p>{form.error}</p>
<!-- Los pasos del run SÍ se cargan al fallar, pero viven dentro del modal de
instalación y el operador no daba con ellos. Se nombra el run y dónde está,
en vez de dar por hecho que se encuentra. -->
{#if progressRunId}
<p class="mt-1 text-xs text-red-700">
Detalle paso a paso en el run #{progressRunId}, en el recuadro de
progreso del formulario de instalación.
</p>
{/if}
</div>
<button
type="button"
aria-label="Cerrar aviso"