138 lines
4.9 KiB
TypeScript
138 lines
4.9 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
const run = promisify(execFile);
|
|
|
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
/** Marqueur attendu dans la sortie de `--check` : un bundle cassé ne l'imprime pas. */
|
|
const HEALTH_MARKER = 'obs-websocket';
|
|
|
|
export interface UpdateResult {
|
|
previousBuildId: string;
|
|
newBuildId: string;
|
|
bytes: number;
|
|
backup: string;
|
|
restartInMs: number;
|
|
}
|
|
|
|
/** Chemin du bundle en cours d'exécution, ou null hors déploiement bundlé. */
|
|
export function runningBundlePath(): string | null {
|
|
const entry = process.argv[1];
|
|
if (!entry) return null;
|
|
const resolved = path.resolve(entry);
|
|
return resolved.endsWith('.cjs') && fs.existsSync(resolved) ? resolved : null;
|
|
}
|
|
|
|
/** Empreinte courte du binaire courant, pour repérer les agents en retard. */
|
|
export function currentBuildId(): string | undefined {
|
|
const bundle = runningBundlePath();
|
|
if (!bundle) return undefined;
|
|
try {
|
|
return createHash('sha256').update(fs.readFileSync(bundle)).digest('hex').slice(0, 8);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export interface UpdateDeps {
|
|
/** État d'enregistrement : on ne coupe jamais une capture en cours. */
|
|
recordState(): Promise<{ active: boolean; paused: boolean }>;
|
|
log(level: 'info' | 'warn' | 'error', message: string): void;
|
|
}
|
|
|
|
/**
|
|
* Remplace le binaire de l'agent puis rend la main au superviseur.
|
|
*
|
|
* Le processus ne se relance pas lui-même : il sort, et systemd (Linux) ou la
|
|
* tâche planifiée (Windows) le redémarre. C'est ce qui garde le mécanisme
|
|
* simple — et c'est aussi ce qui impose de valider le nouveau binaire *avant*
|
|
* de basculer, faute de quoi un bundle cassé produirait une boucle de crash
|
|
* sans personne pour revenir en arrière.
|
|
*/
|
|
export async function selfUpdate(
|
|
url: string | undefined,
|
|
deps: UpdateDeps,
|
|
): Promise<UpdateResult> {
|
|
const bundle = runningBundlePath();
|
|
if (!bundle) {
|
|
throw new Error(
|
|
"Mise à jour indisponible : l'agent ne tourne pas depuis un bundle (mode développement ?)",
|
|
);
|
|
}
|
|
if (!url) {
|
|
throw new Error(
|
|
"Aucune URL de paquet connue. Réinstalle l'agent une fois pour l'enregistrer, ou fournis-la explicitement.",
|
|
);
|
|
}
|
|
|
|
// 1. Ne jamais interrompre une capture en cours.
|
|
const record = await deps.recordState().catch(() => ({ active: false, paused: false }));
|
|
if (record.active) {
|
|
throw new Error('Enregistrement en cours : mise à jour refusée. Arrête-le puis réessaie.');
|
|
}
|
|
|
|
const dir = path.dirname(bundle);
|
|
const staged = path.join(dir, 'agent-update.cjs');
|
|
const backup = path.join(dir, 'agent-previous.cjs');
|
|
|
|
// 2. Téléchargement.
|
|
deps.log('info', `Mise à jour depuis ${url}`);
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
if (!response.ok) throw new Error(`Téléchargement impossible : HTTP ${response.status}`);
|
|
|
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
if (bytes.length < 10_000) {
|
|
throw new Error(`Paquet suspect : ${bytes.length} octets seulement`);
|
|
}
|
|
fs.writeFileSync(staged, bytes);
|
|
|
|
const newBuildId = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
const previousBuildId = currentBuildId() ?? 'inconnu';
|
|
|
|
if (newBuildId === previousBuildId) {
|
|
fs.rmSync(staged, { force: true });
|
|
throw new Error(`Déjà à jour (build ${previousBuildId})`);
|
|
}
|
|
|
|
try {
|
|
// 3. Le fichier est-il seulement du JavaScript analysable ?
|
|
await run(process.execPath, ['--check', staged], { timeout: 30_000 });
|
|
|
|
// 4. S'exécute-t-il vraiment ? `--check` sort en 1 quand le serveur est
|
|
// injoignable : on valide la présence du diagnostic, pas le code retour.
|
|
const probe = await run(process.execPath, [staged, '--check'], {
|
|
timeout: 60_000,
|
|
env: { ...process.env, AGENT_TOKEN: process.env.AGENT_TOKEN ?? 'update-selftest' },
|
|
}).catch((err: { stdout?: string; stderr?: string }) => ({
|
|
stdout: err.stdout ?? '',
|
|
stderr: err.stderr ?? '',
|
|
}));
|
|
|
|
if (!`${probe.stdout}${probe.stderr}`.includes(HEALTH_MARKER)) {
|
|
throw new Error('le nouveau binaire ne produit pas de diagnostic valide');
|
|
}
|
|
} catch (err) {
|
|
fs.rmSync(staged, { force: true });
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Nouveau binaire rejeté (${detail}) — binaire actuel conservé`);
|
|
}
|
|
|
|
// 5. Bascule, en gardant de quoi revenir en arrière à la main.
|
|
fs.copyFileSync(bundle, backup);
|
|
fs.renameSync(staged, bundle);
|
|
|
|
deps.log(
|
|
'info',
|
|
`Binaire remplacé : ${previousBuildId} → ${newBuildId}. Redémarrage par le superviseur…`,
|
|
);
|
|
|
|
// 6. Sortie différée : laisse le temps au résultat de partir vers le serveur.
|
|
const restartInMs = 1500;
|
|
setTimeout(() => process.exit(0), restartInMs).unref?.();
|
|
|
|
return { previousBuildId, newBuildId, bytes: bytes.length, backup, restartInMs };
|
|
}
|