CI : Update agent from web
This commit is contained in:
@@ -129,6 +129,16 @@ if ($isUpgrade) {
|
|||||||
}
|
}
|
||||||
$applied = @()
|
$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('Server')) { $current.serverUrl = $Server; $applied += 'serverUrl' }
|
||||||
if ($PSBoundParameters.ContainsKey('Name')) { $current.name = $Name; $applied += 'name' }
|
if ($PSBoundParameters.ContainsKey('Name')) { $current.name = $Name; $applied += 'name' }
|
||||||
if ($PSBoundParameters.ContainsKey('ObsHost')) { $current.obs.host = $ObsHost; $applied += 'obs.host' }
|
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'
|
Info 'Configuration mise à jour, identité de l''agent préservée'
|
||||||
} else {
|
} else {
|
||||||
$config = [ordered]@{
|
$config = [ordered]@{
|
||||||
serverUrl = $Server
|
serverUrl = $Server
|
||||||
token = $Token
|
token = $Token
|
||||||
name = $Name
|
name = $Name
|
||||||
obs = [ordered]@{ host = $ObsHost; port = $ObsPort; password = $ObsPassword }
|
packageUrl = $url
|
||||||
|
obs = [ordered]@{ host = $ObsHost; port = $ObsPort; password = $ObsPassword }
|
||||||
}
|
}
|
||||||
# Surtout pas Set-Content -Encoding UTF8 : sous PowerShell 5.1 il préfixe un
|
# Surtout pas Set-Content -Encoding UTF8 : sous PowerShell 5.1 il préfixe un
|
||||||
# BOM, et JSON.parse côté Node refuse le fichier.
|
# BOM, et JSON.parse côté Node refuse le fichier.
|
||||||
|
|||||||
@@ -185,12 +185,19 @@ if [ "$IS_UPGRADE" = true ]; then
|
|||||||
node -e '
|
node -e '
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const a = process.argv.slice(1);
|
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 current = JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
||||||
const next = { ...current, obs: { ...(current.obs || {}) } };
|
const next = { ...current, obs: { ...(current.obs || {}) } };
|
||||||
const applied = [];
|
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 (serverUrl) { next.serverUrl = serverUrl; applied.push("serverUrl"); }
|
||||||
if (name) { next.name = name; applied.push("name"); }
|
if (name) { next.name = name; applied.push("name"); }
|
||||||
if (obsHost) { next.obs.host = obsHost; applied.push("obs.host"); }
|
if (obsHost) { next.obs.host = obsHost; applied.push("obs.host"); }
|
||||||
@@ -210,7 +217,7 @@ console.log(applied.length
|
|||||||
? " champs mis à jour : " + applied.join(", ")
|
? " champs mis à jour : " + applied.join(", ")
|
||||||
: " aucun changement demandé");
|
: " aucun changement demandé");
|
||||||
' "$CONFIG_FILE" "$SERVER_URL" "$AGENT_NAME" "$OBS_HOST" "$OBS_PORT" \
|
' "$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)."
|
|| die "Fusion de la configuration existante impossible ($CONFIG_FILE)."
|
||||||
|
|
||||||
info "Configuration mise à jour, identité de l'agent préservée"
|
info "Configuration mise à jour, identité de l'agent préservée"
|
||||||
@@ -221,6 +228,7 @@ else
|
|||||||
"serverUrl": "$SERVER_URL",
|
"serverUrl": "$SERVER_URL",
|
||||||
"token": "$TOKEN",
|
"token": "$TOKEN",
|
||||||
"name": "$AGENT_NAME",
|
"name": "$AGENT_NAME",
|
||||||
|
"packageUrl": "$URL",
|
||||||
"obs": {
|
"obs": {
|
||||||
"host": "$OBS_HOST",
|
"host": "$OBS_HOST",
|
||||||
"port": $OBS_PORT,
|
"port": $OBS_PORT,
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface AgentConfig {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
|
/** Repli local si le serveur n'a pas encore poussé de configuration OBS. */
|
||||||
obs: ObsSettings;
|
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é). */
|
/** Ignorer les erreurs de certificat TLS (utile en auto-signé). */
|
||||||
insecureTls?: boolean;
|
insecureTls?: boolean;
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,7 @@ export function loadConfig(): AgentConfig {
|
|||||||
port: Number(process.env.OBS_PORT ?? file.obs?.port ?? DEFAULT_OBS_SETTINGS.port),
|
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,
|
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,
|
insecureTls: process.env.INSECURE_TLS === '1' || file.insecureTls === true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
|||||||
import { runDiagnostics } from './doctor.ts';
|
import { runDiagnostics } from './doctor.ts';
|
||||||
import { ObsController } from './obs.ts';
|
import { ObsController } from './obs.ts';
|
||||||
import { StreamWatcher } from './watcher.ts';
|
import { StreamWatcher } from './watcher.ts';
|
||||||
|
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
||||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||||
|
|
||||||
const AGENT_VERSION = '0.1.0';
|
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_MIN_MS = 1000;
|
||||||
const RECONNECT_MAX_MS = 30_000;
|
const RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
@@ -194,6 +197,14 @@ async function runAction(action: AgentAction, params: Record<string, unknown>):
|
|||||||
return watcher.checkNow();
|
return watcher.checkNow();
|
||||||
case 'hotkey.fullscreen':
|
case 'hotkey.fullscreen':
|
||||||
return watcher.restoreFullscreen();
|
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:
|
default:
|
||||||
return obs.execute(action, params);
|
return obs.execute(action, params);
|
||||||
}
|
}
|
||||||
@@ -214,6 +225,8 @@ async function buildStatus(): Promise<AgentStatus> {
|
|||||||
...emptyStatus(),
|
...emptyStatus(),
|
||||||
...snapshot,
|
...snapshot,
|
||||||
watch: watcher.snapshot,
|
watch: watcher.snapshot,
|
||||||
|
buildId: BUILD_ID,
|
||||||
|
canSelfUpdate: Boolean(runningBundlePath() && config.packageUrl),
|
||||||
lastRecordingPath: obs.recordingPath,
|
lastRecordingPath: obs.recordingPath,
|
||||||
systemCpu: cpuUsagePercent(),
|
systemCpu: cpuUsagePercent(),
|
||||||
systemMemoryUsed: memory.used,
|
systemMemoryUsed: memory.used,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import OBSWebSocket from 'obs-websocket-js';
|
|||||||
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
|
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actions relevant d'OBS. `watch.*` et `hotkey.*` sont traitées en amont par
|
* Actions relevant d'OBS. `watch.*`, `hotkey.*` et `agent.update` sont traitées
|
||||||
* l'agent : elles ne concernent pas la session obs-websocket.
|
* 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<
|
type ObsSnapshot = Pick<
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
|
|||||||
137
packages/agent/src/updater.ts
Normal file
137
packages/agent/src/updater.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export const AGENT_ACTIONS = [
|
|||||||
'recordDirectory.set',
|
'recordDirectory.set',
|
||||||
'watch.check',
|
'watch.check',
|
||||||
'hotkey.fullscreen',
|
'hotkey.fullscreen',
|
||||||
|
'agent.update',
|
||||||
'agent.ping',
|
'agent.ping',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -198,6 +199,15 @@ export interface AgentStatus {
|
|||||||
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
/** Surveillance du stream source, absente si elle n'est pas configurée. */
|
||||||
watch?: WatchState;
|
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;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
|||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
{agent.hostname ?? '—'} · {agent.platform}
|
{agent.hostname ?? '—'} · {agent.platform}
|
||||||
{agent.agentVersion ? ` · v${agent.agentVersion}` : ''}
|
{agent.agentVersion ? ` · v${agent.agentVersion}` : ''}
|
||||||
|
{status.buildId ? ` · build ${status.buildId}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`badge ${state.tone}`}>{state.label}</span>
|
<span className={`badge ${state.tone}`}>{state.label}</span>
|
||||||
@@ -148,6 +149,25 @@ export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSe
|
|||||||
Connecter OBS
|
Connecter OBS
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status.canSelfUpdate && (
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
disabled={!agent.online || busy || status.recording}
|
||||||
|
title={
|
||||||
|
status.recording
|
||||||
|
? 'Enregistrement en cours — la mise à jour est refusée'
|
||||||
|
: 'Télécharger et installer la dernière version de l\'agent'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Mettre à jour « ${agent.name} » ? L'agent redémarrera.`)) {
|
||||||
|
void run('agent.update');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⬆ Mettre à jour
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="card-foot muted small">
|
<footer className="card-foot muted small">
|
||||||
|
|||||||
Reference in New Issue
Block a user