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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||
export async function GET({ params }: { params: { file: string } }) {
|
||||
if (!['ec-agent.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
|
||||
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
|
||||
if (!['ec-agent.py','ec-agent-update.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
|
||||
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import { hostAgentUpdateLabel, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
@@ -51,7 +51,7 @@
|
||||
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
|
||||
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if}
|
||||
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
|
||||
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if host.readiness === 'needs-game-files'}<Button size="sm" href="/game-files">Choose game files</Button>{/if}{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
|
||||
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if host.readiness === 'needs-game-files'}<Button size="sm" href="/game-files">Choose game files</Button>{/if}{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
|
||||
import { hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
@@ -50,6 +50,13 @@
|
||||
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
|
||||
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section></div>
|
||||
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2>
|
||||
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl>
|
||||
{#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if}
|
||||
{#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if}
|
||||
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
|
||||
{#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access below, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if}
|
||||
</section>
|
||||
<div class="grid"><section class="panel"><h2>Game files and runtime</h2><p><a href="/game-files">Manage game-file libraries →</a></p>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported.</p>{/if}
|
||||
{#if canManage}<form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Prepare runtime</Button></fieldset></form>{/if}<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported'}</p>
|
||||
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||||
|
||||
Reference in New Issue
Block a user