mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(fleet): add remote node update management (#353)
Add the ability to check for outdated nodes and trigger over-the-air updates from Fleet View. Nodes self-update by pulling the latest Docker image and recreating their container via the "last breath" pattern. Backend: - SelfUpdateService: self-container identification via HOSTNAME + Docker Compose labels, triggers pull + force-recreate - CapabilityRegistry: runtime capability disabling via disableCapability() - POST /api/system/update (202 + deferred self-update) - GET /api/fleet/update-status (version comparison across fleet) - POST /api/fleet/nodes/:nodeId/update (single node) - POST /api/fleet/update-all (bulk remote update) - In-memory update tracker with 5-min timeout Frontend: - Node Updates modal with summary stats, search filter, table layout, per-node Update buttons, and bulk Update All - Version badges and update-available indicators on node cards - ReconnectingOverlay for local node updates (polls /api/health) - 5s fast-poll when any node is actively updating - UpdateStatusBadge shared component for consistent badge rendering Requires Skipper (Pro) tier. Nodes must be deployed via Docker Compose with Docker socket access.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { execSync, exec } from 'child_process';
|
||||
import DockerController from './DockerController';
|
||||
import { disableCapability } from './CapabilityRegistry';
|
||||
|
||||
interface ComposeContext {
|
||||
workingDir: string;
|
||||
configFiles: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
class SelfUpdateService {
|
||||
private static instance: SelfUpdateService;
|
||||
private canSelfUpdate = false;
|
||||
private composeContext: ComposeContext | null = null;
|
||||
|
||||
public static getInstance(): SelfUpdateService {
|
||||
if (!SelfUpdateService.instance) {
|
||||
SelfUpdateService.instance = new SelfUpdateService();
|
||||
}
|
||||
return SelfUpdateService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const hostname = process.env.HOSTNAME;
|
||||
if (!hostname) {
|
||||
console.log('[SelfUpdate] HOSTNAME not set — self-update unavailable (not running in Docker?)');
|
||||
disableCapability('self-update');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const docker = DockerController.getInstance().getDocker();
|
||||
const container = docker.getContainer(hostname);
|
||||
const info = await container.inspect();
|
||||
const labels = info.Config?.Labels ?? {};
|
||||
|
||||
const workingDir = labels['com.docker.compose.project.working_dir'];
|
||||
const configFiles = labels['com.docker.compose.project.config_files'];
|
||||
const serviceName = labels['com.docker.compose.service'];
|
||||
|
||||
if (!workingDir || !configFiles || !serviceName) {
|
||||
console.log('[SelfUpdate] Container lacks Docker Compose labels — self-update unavailable');
|
||||
disableCapability('self-update');
|
||||
return;
|
||||
}
|
||||
|
||||
this.composeContext = { workingDir, configFiles, serviceName };
|
||||
this.canSelfUpdate = true;
|
||||
console.log(`[SelfUpdate] Ready — service="${serviceName}" in ${workingDir}`);
|
||||
} catch (error) {
|
||||
console.log('[SelfUpdate] Could not inspect own container — self-update unavailable:', (error as Error).message);
|
||||
disableCapability('self-update');
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.canSelfUpdate;
|
||||
}
|
||||
|
||||
triggerUpdate(): void {
|
||||
if (!this.composeContext) return;
|
||||
const { workingDir, configFiles, serviceName } = this.composeContext;
|
||||
const env = { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' };
|
||||
|
||||
console.log(`[SelfUpdate] Pulling latest image for ${serviceName}...`);
|
||||
try {
|
||||
execSync(`docker compose -f ${configFiles} pull ${serviceName}`, {
|
||||
cwd: workingDir,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000, // 5 min max for pull
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SelfUpdate] Pull failed:', (error as Error).message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SelfUpdate] Recreating container for ${serviceName}... (last breath)`);
|
||||
exec(`docker compose -f ${configFiles} up -d --force-recreate ${serviceName}`, {
|
||||
cwd: workingDir,
|
||||
env,
|
||||
});
|
||||
// Process will be killed by Docker during recreate — no code runs after this
|
||||
}
|
||||
}
|
||||
|
||||
export default SelfUpdateService;
|
||||
Reference in New Issue
Block a user