diff --git a/deploy/install-agent.ps1 b/deploy/install-agent.ps1 index 2a2463f..10ae937 100644 --- a/deploy/install-agent.ps1 +++ b/deploy/install-agent.ps1 @@ -129,6 +129,16 @@ if ($isUpgrade) { } $applied = @() + # Toujours rafraîchie : ce n'est pas de l'identité, c'est de l'infrastructure. + # Sans elle, l'agent ne saurait pas où chercher sa propre mise à jour. + if ($current.PSObject.Properties.Name -notcontains 'packageUrl') { + $current | Add-Member -NotePropertyName packageUrl -NotePropertyValue $url -Force + $applied += 'packageUrl' + } elseif ($current.packageUrl -ne $url) { + $current.packageUrl = $url + $applied += 'packageUrl' + } + if ($PSBoundParameters.ContainsKey('Server')) { $current.serverUrl = $Server; $applied += 'serverUrl' } if ($PSBoundParameters.ContainsKey('Name')) { $current.name = $Name; $applied += 'name' } if ($PSBoundParameters.ContainsKey('ObsHost')) { $current.obs.host = $ObsHost; $applied += 'obs.host' } @@ -151,10 +161,11 @@ if ($isUpgrade) { Info 'Configuration mise à jour, identité de l''agent préservée' } else { $config = [ordered]@{ - serverUrl = $Server - token = $Token - name = $Name - obs = [ordered]@{ host = $ObsHost; port = $ObsPort; password = $ObsPassword } + serverUrl = $Server + token = $Token + name = $Name + packageUrl = $url + obs = [ordered]@{ host = $ObsHost; port = $ObsPort; password = $ObsPassword } } # Surtout pas Set-Content -Encoding UTF8 : sous PowerShell 5.1 il préfixe un # BOM, et JSON.parse côté Node refuse le fichier. diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index ee461e0..32eb167 100644 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -185,12 +185,19 @@ if [ "$IS_UPGRADE" = true ]; then node -e ' const fs = require("node:fs"); const a = process.argv.slice(1); -const [file, serverUrl, name, obsHost, obsPort, pwFlag, obsPassword, resetFlag, token] = a; +const [file, serverUrl, name, obsHost, obsPort, pwFlag, obsPassword, resetFlag, token, packageUrl] = a; const current = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")); const next = { ...current, obs: { ...(current.obs || {}) } }; const applied = []; +// Toujours rafraichie : infrastructure et non identite. Sans elle, un agent +// ne saurait pas ou chercher sa propre mise a jour. +// (Pas d apostrophe dans ce bloc : il vit dans une chaine bash entre quotes.) +if (packageUrl && packageUrl !== current.packageUrl) { + next.packageUrl = packageUrl; + applied.push("packageUrl"); +} if (serverUrl) { next.serverUrl = serverUrl; applied.push("serverUrl"); } if (name) { next.name = name; applied.push("name"); } if (obsHost) { next.obs.host = obsHost; applied.push("obs.host"); } @@ -210,7 +217,7 @@ console.log(applied.length ? " champs mis à jour : " + applied.join(", ") : " aucun changement demandé"); ' "$CONFIG_FILE" "$SERVER_URL" "$AGENT_NAME" "$OBS_HOST" "$OBS_PORT" \ - "$PW_FLAG" "$OBS_PASSWORD" "$RESET_FLAG" "$TOKEN" \ + "$PW_FLAG" "$OBS_PASSWORD" "$RESET_FLAG" "$TOKEN" "$URL" \ || die "Fusion de la configuration existante impossible ($CONFIG_FILE)." info "Configuration mise à jour, identité de l'agent préservée" @@ -221,6 +228,7 @@ else "serverUrl": "$SERVER_URL", "token": "$TOKEN", "name": "$AGENT_NAME", + "packageUrl": "$URL", "obs": { "host": "$OBS_HOST", "port": $OBS_PORT, diff --git a/packages/agent/src/config.ts b/packages/agent/src/config.ts index 5e4394e..83bcd11 100644 --- a/packages/agent/src/config.ts +++ b/packages/agent/src/config.ts @@ -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, }; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3c6bfdd..ca086d9 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -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): 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 { ...emptyStatus(), ...snapshot, watch: watcher.snapshot, + buildId: BUILD_ID, + canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl), lastRecordingPath: obs.recordingPath, systemCpu: cpuUsagePercent(), systemMemoryUsed: memory.used, diff --git a/packages/agent/src/obs.ts b/packages/agent/src/obs.ts index 71a63f3..c5f8b77 100644 --- a/packages/agent/src/obs.ts +++ b/packages/agent/src/obs.ts @@ -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; +export type ObsAction = Exclude< + AgentAction, + 'watch.check' | 'hotkey.fullscreen' | 'agent.update' +>; type ObsSnapshot = Pick< AgentStatus, diff --git a/packages/agent/src/updater.ts b/packages/agent/src/updater.ts new file mode 100644 index 0000000..60685bb --- /dev/null +++ b/packages/agent/src/updater.ts @@ -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 { + 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 }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 40a781a..1382602 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -45,6 +45,7 @@ export const AGENT_ACTIONS = [ 'recordDirectory.set', 'watch.check', 'hotkey.fullscreen', + 'agent.update', 'agent.ping', ] as const; @@ -198,6 +199,15 @@ export interface AgentStatus { /** Surveillance du stream source, absente si elle n'est pas configurée. */ watch?: WatchState; + /** + * Empreinte courte du binaire en cours d'exécution. Deux agents partageant + * cette valeur tournent sur le même build — c'est ce qui rend visible un + * agent resté en arrière après une mise à jour. + */ + buildId?: string; + /** Vrai si l'agent sait se mettre à jour seul (URL de paquet connue). */ + canSelfUpdate?: boolean; + updatedAt: number; } diff --git a/packages/web/src/components/AgentCard.tsx b/packages/web/src/components/AgentCard.tsx index 572595a..8e05799 100644 --- a/packages/web/src/components/AgentCard.tsx +++ b/packages/web/src/components/AgentCard.tsx @@ -56,6 +56,7 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe {agent.hostname ?? '—'} · {agent.platform} {agent.agentVersion ? ` · v${agent.agentVersion}` : ''} + {status.buildId ? ` · build ${status.buildId}` : ''} {state.label} @@ -148,6 +149,25 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe Connecter OBS )} + + {status.canSelfUpdate && ( + + )}