Add clear host deletion controls with workload safeguards

This commit is contained in:
2026-10-04 09:59:27 -04:00
parent 840f02b53e
commit 3d62dfff27
11 changed files with 334 additions and 13 deletions
+2
View File
@@ -2,6 +2,8 @@ export type HostMode = 'active' | 'draining' | 'disabled';
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
export interface HostDeletion { allowed: boolean; blockers: string[] }
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
export interface HostInventory {
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
+49 -3
View File
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type { Host, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
import type { Host, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000;
@@ -33,12 +33,57 @@ function hostFromRow(row: HostRow): Host {
readiness: row.mode === 'draining' ? 'draining' : row.mode === 'disabled' ? 'disabled' : !row.enrolled_at || (infrastructureReady && reasons.length) ? 'preparing' : reasons.length ? 'degraded' : 'ready',
readinessReasons: reasons, inventory, enrolled: !!row.enrolled_at, credentialActive: !!row.credential_active };
}
export function listHosts(): Host[] { return (database().prepare(hostSql + ' ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
export function listHosts(): Host[] { return (database().prepare(hostSql + ' WHERE h.deleted_at IS NULL ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
export function getHost(id: string): Host | null {
const row = database().prepare(hostSql + ' WHERE h.id = ?').get(id) as HostRow | undefined;
const row = database().prepare(hostSql + ' WHERE h.id = ? AND h.deleted_at IS NULL').get(id) as HostRow | undefined;
return row ? hostFromRow(row) : null;
}
function requireHost(id: string): Host { const host = getHost(id); if (!host) throw new Error('Host was not found.'); return host; }
export class HostDeletionError extends Error {
constructor(message: string, readonly status: 404 | 409, readonly blockers: string[] = []) { super(message); }
}
/** Controller records can be removed only after all remote instance ownership is resolved. */
export function hostDeletionStatus(id: string): HostDeletion {
if (!getHost(id)) throw new HostDeletionError('Host not found.', 404);
const handle = database();
const blockers: string[] = [];
const instances = handle.prepare('SELECT server FROM native_instances WHERE host_id=? AND retired_at IS NULL ORDER BY server').all(id) as { server: string }[];
if (instances.length) {
const names = instances.slice(0, 5).map(instance => instance.server).join(', ') + (instances.length > 5 ? ', …' : '');
blockers.push(`Remove the ${instances.length} server instance${instances.length === 1 ? '' : 's'} assigned to this host first (${names}). Queued and failed deployments also require removal.`);
}
const ports = handle.prepare('SELECT COUNT(*) AS count FROM ec_host_ports WHERE host_id=?').get(id) as { count: number };
if (ports.count && !instances.length) blockers.push(`This host still has ${ports.count} reserved game port${ports.count === 1 ? '' : 's'}. Finish or reconcile the corresponding server removals first.`);
// A pending command may have reached the agent before its acknowledgement.
// Only commands that have never been delivered are safe to cancel locally.
const operations = handle.prepare(`SELECT kind,state FROM ec_host_operations WHERE host_id=?
AND (state IN ('accepted','running','unknown') OR (state='pending' AND last_delivered_at IS NOT NULL))
ORDER BY created_at`).all(id) as { kind: string; state: string }[];
if (operations.length) {
const work = operations.slice(0, 5).map(operation => `${operation.kind}: ${operation.state}`).join(', ') + (operations.length > 5 ? ', …' : '');
blockers.push(`${operations.length} delivered operation${operations.length === 1 ? ' is' : 's are'} unresolved (${work}). Let the agent reconnect and finish or reconcile this work before deleting the host.`);
}
return { allowed: blockers.length === 0, blockers };
}
/** Retain references and audit history; deletion never sends remote cleanup commands. */
export function deleteHost(id: string): void {
const handle = database();
handle.transaction(() => {
const status = hostDeletionStatus(id);
if (!status.allowed) throw new HostDeletionError(status.blockers.join(' '), 409, status.blockers);
const timestamp = now();
const reason = 'Host deleted before this operation was delivered to the agent.';
handle.prepare(`INSERT INTO ec_host_operation_events(operation_id,state,created_at,error)
SELECT id,'failed',?,? FROM ec_host_operations WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL`).run(timestamp, reason, id);
handle.prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL").run(reason, timestamp, id);
handle.prepare('UPDATE ec_host_credentials SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL').run(timestamp, id);
handle.prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE host_id=? AND consumed_at IS NULL').run(timestamp, id);
handle.prepare("UPDATE ec_hosts SET deleted_at=?,mode='disabled' WHERE id=? AND deleted_at IS NULL").run(timestamp, id);
}).immediate();
}
function textField(value: unknown, label: string, max = 200): string {
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) throw new Error(`${label} must be a nonempty string up to ${max} characters.`);
return value.trim();
@@ -195,6 +240,7 @@ export function pendingHostOperations(hostId: string): HostOperation[] {
}
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
return database().transaction(() => {
requireHost(hostId);
const operation = getHostOperation(id);
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
if (!['accepted','running','completed','failed','unknown'].includes(input.state as string)) throw new Error('Invalid operation state.');
+3 -1
View File
@@ -10,7 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void {
port_min INTEGER NOT NULL DEFAULT 28960, port_max INTEGER NOT NULL DEFAULT 28980,
bind_address TEXT NOT NULL DEFAULT '0.0.0.0', public_address TEXT NOT NULL DEFAULT '',
minimum_free_bytes INTEGER NOT NULL DEFAULT 1073741824,
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}'
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}', deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS ec_host_enrollments (
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
@@ -43,5 +43,7 @@ export function ensureHostSchema(handle: Database.Database): void {
`);
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
if (!columns.some(column => column.name === 'last_delivered_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN last_delivered_at TEXT');
const hostColumns = handle.prepare('PRAGMA table_info(ec_hosts)').all() as { name: string }[];
if (!hostColumns.some(column => column.name === 'deleted_at')) handle.exec('ALTER TABLE ec_hosts ADD COLUMN deleted_at TEXT');
initialized.add(handle);
}
@@ -0,0 +1,65 @@
<script lang="ts">
import type { Host, HostDeletion } from '$lib/hosts.js';
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), host, ondeleted }:
{ open?: boolean; host: Pick<Host, 'id' | 'name'> | null; ondeleted: () => void | Promise<void> } = $props();
let deletion = $state<HostDeletion | null>(null);
let checking = $state(false); let deleting = $state(false); let error = $state('');
async function inspect(id: string, signal?: AbortSignal) {
checking = true; deletion = null; error = '';
try {
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not check this host.');
if (!signal?.aborted) deletion = body.deletion;
} catch (cause) { if (!signal?.aborted) error = (cause as Error).message; }
finally { if (!signal?.aborted) checking = false; }
}
$effect(() => {
if (!open || !host) return;
const controller = new AbortController();
void inspect(host.id, controller.signal);
return () => controller.abort();
});
async function remove() {
if (!host || deleting || checking || !deletion?.allowed) return;
const id = host.id;
deleting = true; error = '';
try {
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { method: 'DELETE' });
const body = await response.json();
if (!response.ok) {
await inspect(id);
throw new Error(body.error ?? body.message ?? 'Could not delete this host.');
}
open = false;
await ondeleted();
} catch (cause) { error = (cause as Error).message; }
finally { deleting = false; }
}
</script>
<Dialog bind:open title={`Delete ${host?.name ?? 'host'}?`} busy={deleting}
description="Remove this host from Electric Cherry and revoke its agent access and unused installer links.">
<div class="content">
<p>The agent and game files stay on the machine. Published game libraries and operation history are kept. Commands that have never been sent to the host will be cancelled.</p>
{#if checking}<p role="status">Checking servers and unfinished operations…</p>
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to review its operations or drain its servers.</p></div>
{:else if deletion?.allowed}<p>This host is ready to delete. Adding it again requires a new enrollment.</p>{/if}
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
</div>
{#snippet actions()}
<Button variant="ghost" disabled={deleting} onclick={() => open = false}>Cancel</Button>
{#if !deletion?.allowed}<Button disabled={checking || deleting || !host} onclick={() => { if (host) void inspect(host.id); }}>Check again</Button>{/if}
<Button variant="danger" loading={deleting} disabled={checking || !deletion?.allowed} onclick={remove}>Delete host</Button>
{/snippet}
</Dialog>
<style>
.content { display:grid; gap:16px; } p,li { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.65; }
.blocked { display:grid; gap:12px; } strong { font-size:var(--text-sm); } ul { margin:0; padding-left:20px; }
a { color:var(--accent-primary-hover); } .ui-error { color:var(--state-danger); }
</style>