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:
Anso
2026-04-03 01:39:22 -04:00
committed by GitHub
parent d670984635
commit 87b5908288
13 changed files with 1039 additions and 21 deletions
+87
View File
@@ -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;