CI : Update agent from web
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 59s

This commit is contained in:
jeanotx32
2026-08-11 20:16:08 +02:00
parent 4f7b8f56ae
commit 6916ff4be3
8 changed files with 217 additions and 9 deletions

View File

@@ -15,6 +15,11 @@ export interface AgentConfig {
name: string;
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
obs: ObsSettings;
/**
* URL du bundle dans le registre, écrite par le script d'installation. Sans
* elle, l'agent ne sait pas où chercher sa propre mise à jour.
*/
packageUrl?: string;
/** Ignorer les erreurs de certificat TLS (utile en auto-signé). */
insecureTls?: boolean;
}
@@ -50,6 +55,7 @@ export function loadConfig(): AgentConfig {
port: Number(process.env.OBS_PORT ?? file.obs?.port ?? DEFAULT_OBS_SETTINGS.port),
password: process.env.OBS_PASSWORD ?? file.obs?.password ?? DEFAULT_OBS_SETTINGS.password,
},
packageUrl: process.env.AGENT_PACKAGE_URL ?? file.packageUrl,
insecureTls: process.env.INSECURE_TLS === '1' || file.insecureTls === true,
};

View File

@@ -21,9 +21,12 @@ import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
import { runDiagnostics } from './doctor.ts';
import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
const AGENT_VERSION = '0.1.0';
/** Empreinte du bundle courant, calculée une fois au démarrage. */
const BUILD_ID = currentBuildId();
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
@@ -194,6 +197,14 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
return watcher.checkNow();
case 'hotkey.fullscreen':
return watcher.restoreFullscreen();
case 'agent.update':
return selfUpdate(
typeof params.url === 'string' && params.url ? params.url : config.packageUrl,
{
recordState: () => obs.recordState(),
log: (level, message) => report(level, message),
},
);
default:
return obs.execute(action, params);
}
@@ -214,6 +225,8 @@ async function buildStatus(): Promise<AgentStatus> {
...emptyStatus(),
...snapshot,
watch: watcher.snapshot,
buildId: BUILD_ID,
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
lastRecordingPath: obs.recordingPath,
systemCpu: cpuUsagePercent(),
systemMemoryUsed: memory.used,

View File

@@ -3,10 +3,13 @@ import OBSWebSocket from 'obs-websocket-js';
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
/**
* Actions relevant d'OBS. `watch.*` et `hotkey.*` sont traitées en amont par
* l'agent : elles ne concernent pas la session obs-websocket.
* Actions relevant d'OBS. `watch.*`, `hotkey.*` et `agent.update` sont traitées
* en amont par l'agent : elles ne concernent pas la session obs-websocket.
*/
export type ObsAction = Exclude<AgentAction, 'watch.check' | 'hotkey.fullscreen'>;
export type ObsAction = Exclude<
AgentAction,
'watch.check' | 'hotkey.fullscreen' | 'agent.update'
>;
type ObsSnapshot = Pick<
AgentStatus,

View File

@@ -0,0 +1,137 @@
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 };
}