Add automatic agent updates with verified rollback
This commit is contained in:
@@ -24,6 +24,40 @@ export function hostDeletionMessage(host: Pick<Host, 'connected' | 'decommission
|
||||
}
|
||||
|
||||
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
|
||||
export interface HostAgentUpdate {
|
||||
enabled: boolean;
|
||||
state: 'current' | 'checking' | 'deferred' | 'updating' | 'updated' | 'failed';
|
||||
currentVersion: string;
|
||||
targetVersion?: string;
|
||||
error?: string;
|
||||
checkedAt?: string;
|
||||
}
|
||||
|
||||
export function hostAgentUpdatesEnabled(inventory: HostInventory): boolean {
|
||||
return inventory.agentUpdate?.enabled === true && inventory.capabilities?.includes('agent.update') === true;
|
||||
}
|
||||
|
||||
export function hostAgentUpdateLabel(inventory: HostInventory): string {
|
||||
if (!hostAgentUpdatesEnabled(inventory)) return 'Setup required';
|
||||
const update = inventory.agentUpdate!;
|
||||
if (update.state === 'current' && !update.checkedAt) return 'Awaiting first check';
|
||||
return { current: 'Current', checking: 'Checking', deferred: 'Deferred',
|
||||
updating: 'Updating', updated: 'Updated', failed: 'Needs attention' }[update.state];
|
||||
}
|
||||
|
||||
export function hostAgentUpdateMessage(inventory: HostInventory): string {
|
||||
if (!hostAgentUpdatesEnabled(inventory)) return 'Run the current installer once on this host to enable automatic agent updates.';
|
||||
const update = inventory.agentUpdate!;
|
||||
switch (update.state) {
|
||||
case 'current': return update.checkedAt ? 'The last check found no newer compatible agent release.' : 'The host timer will check for a compatible agent release automatically.';
|
||||
case 'checking': return 'The host is checking the controller release and verifying its download.';
|
||||
case 'deferred': return 'The updater is waiting for the reported condition to clear. The host checks again automatically.';
|
||||
case 'updating': return 'The host is replacing agent code and checking that the new process connects successfully.';
|
||||
case 'updated': return 'The new agent connected successfully. Automatic checks continue.';
|
||||
case 'failed': return 'The update did not complete successfully. Review the reported error before retrying setup.';
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostInventory {
|
||||
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
||||
cpu?: string; cpuCount?: number; memoryTotal?: number; memoryAvailable?: number;
|
||||
@@ -31,6 +65,7 @@ export interface HostInventory {
|
||||
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
|
||||
currentOperation?: string | null; [key: string]: unknown;
|
||||
capabilities?: string[];
|
||||
agentUpdate?: HostAgentUpdate;
|
||||
}
|
||||
export interface Host {
|
||||
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
||||
|
||||
function publicAsset(routeId: string | null, path: string, method: string) {
|
||||
return routeId === null && ['GET','HEAD'].includes(method) &&
|
||||
(path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/install.sh','/agent/manifest.json'].includes(path));
|
||||
(path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/ec-agent-update.py','/agent/install.sh','/agent/manifest.json'].includes(path));
|
||||
}
|
||||
|
||||
/** Streaming responses must lose access when the underlying session does. */
|
||||
|
||||
@@ -208,6 +208,22 @@ export function validateInventory(value: unknown): HostInventory {
|
||||
for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`);
|
||||
if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.');
|
||||
if (inventory.capabilities !== undefined && (!Array.isArray(inventory.capabilities) || inventory.capabilities.length > 32 || inventory.capabilities.some(value => typeof value !== 'string' || value.length > 80))) throw new Error('Invalid agent capability inventory.');
|
||||
if (inventory.agentUpdate !== undefined) {
|
||||
const update = inventory.agentUpdate;
|
||||
const version = (value: unknown) => typeof value === 'string' && value.length <= 80 && /^\d+\.\d+\.\d+$/.test(value);
|
||||
if (!update || typeof update !== 'object' || Array.isArray(update) || typeof update.enabled !== 'boolean'
|
||||
|| !['current','checking','deferred','updating','updated','failed'].includes(update.state)
|
||||
|| !version(update.currentVersion) || (update.targetVersion !== undefined && !version(update.targetVersion))
|
||||
|| (update.error !== undefined && (typeof update.error !== 'string' || update.error.length > 2000))
|
||||
|| (update.checkedAt !== undefined && (typeof update.checkedAt !== 'string' || update.checkedAt.length > 80 || !Number.isFinite(Date.parse(update.checkedAt))))) {
|
||||
throw new Error('Invalid agent update inventory.');
|
||||
}
|
||||
// Only expose display fields, never the local recovery journal or secrets.
|
||||
inventory.agentUpdate = { enabled: update.enabled, state: update.state, currentVersion: update.currentVersion,
|
||||
...(update.targetVersion === undefined ? {} : { targetVersion: update.targetVersion }),
|
||||
...(update.error === undefined ? {} : { error: update.error }),
|
||||
...(update.checkedAt === undefined ? {} : { checkedAt: update.checkedAt }) };
|
||||
}
|
||||
if (inventory.libraries !== undefined && (!Array.isArray(inventory.libraries) || inventory.libraries.length > 2000 || inventory.libraries.some(library =>
|
||||
!library || typeof library !== 'object' || typeof library.id !== 'string' || !library.id || library.id.length > 200 || !['missing','downloading','verifying','ready','failed','outdated'].includes(library.state)))) throw new Error('Invalid game library inventory.');
|
||||
return inventory;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/** adapter-node copies public assets to build/client; Vite serves static in development. */
|
||||
export async function agentReleaseAsset(file: 'install.sh' | 'ec-agent.py' | 'manifest.json'): Promise<string> {
|
||||
export async function agentReleaseAsset(file: 'install.sh' | 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'): Promise<string> {
|
||||
// A prior production build must never shadow a freshly packaged development release.
|
||||
if (process.env.NODE_ENV === 'development') return readFile(resolve('static/agent', file), 'utf8');
|
||||
try { return await readFile(resolve('build/client/agent', file), 'utf8'); }
|
||||
catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause;
|
||||
|
||||
Reference in New Issue
Block a user