Add clear host deletion controls with workload safeguards
This commit is contained in:
@@ -31,7 +31,7 @@ guessable token. An unset setup token holds setup closed.
|
|||||||
| Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes |
|
| Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes |
|
||||||
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
|
| Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
|
||||||
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage |
|
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage |
|
||||||
| Host setup, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage |
|
| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage |
|
||||||
| Server keys and global settings/maintenance policies | No | No | Manage |
|
| Server keys and global settings/maintenance policies | No | No | Manage |
|
||||||
| Create users, change roles, disable accounts and reset passwords | No | No | Yes |
|
| Create users, change roles, disable accounts and reset passwords | No | No | Yes |
|
||||||
| Own password and sign out | Yes | Yes | Yes |
|
| Own password and sign out | Yes | Yes | Yes |
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ explicit installer `--reenroll` when local credentials already exist.
|
|||||||
|
|
||||||
API outline:
|
API outline:
|
||||||
|
|
||||||
|
- `GET /api/hosts/:id`: host details, operation history and deletion blockers.
|
||||||
|
- `DELETE /api/hosts/:id`: administrator removes an unused host and revokes access;
|
||||||
|
returns `409` if servers, reservations or unresolved delivered operations remain.
|
||||||
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
- `POST /api/agent/enroll`: consume enrollment token and return host credentials.
|
||||||
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
|
- `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches.
|
||||||
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
- `GET /api/agent/operations`: deliver/reconcile durable operation IDs.
|
||||||
@@ -123,6 +126,18 @@ may still join until an eligible empty moment; draining does not kick them.
|
|||||||
**Disable** holds agent dispatch. Neither mode, controller downtime nor agent
|
**Disable** holds agent dispatch. Neither mode, controller downtime nor agent
|
||||||
communication loss stops unrelated running containers.
|
communication loss stops unrelated running containers.
|
||||||
|
|
||||||
|
Administrators can choose **Delete host** on a host card or at the top of its
|
||||||
|
detail page. The confirmation lists anything that must be resolved first:
|
||||||
|
registered servers, reserved ports, or operations already sent to the agent.
|
||||||
|
Drain the host or remove its servers, then let outstanding operations reconcile.
|
||||||
|
An unused host can be deleted before enrollment or while offline.
|
||||||
|
|
||||||
|
Deleting a host removes it from the controller's host list and deployment
|
||||||
|
choices, revokes its credentials, and invalidates unused installer links.
|
||||||
|
Queued commands that have never been sent to the agent are cancelled. Operation
|
||||||
|
history and published libraries are retained; the agent and game files remain on
|
||||||
|
the machine. Adding the machine again requires a new host enrollment.
|
||||||
|
|
||||||
## Libraries and ports
|
## Libraries and ports
|
||||||
|
|
||||||
Choose the actual game folder, not its parent, a ZIP archive or the Plutonium
|
Choose the actual game folder, not its parent, a ZIP archive or the Plutonium
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type HostMode = 'active' | 'draining' | 'disabled';
|
|||||||
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
|
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
|
||||||
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
|
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 HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
|
||||||
export interface HostInventory {
|
export interface HostInventory {
|
||||||
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { ensureHostSchema } from './schema.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 AGENT_PROTOCOL_VERSION = 1;
|
||||||
export const HEARTBEAT_MAX_AGE_MS = 90_000;
|
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',
|
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 };
|
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 {
|
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;
|
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; }
|
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 {
|
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.`);
|
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();
|
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 {
|
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
|
||||||
return database().transaction(() => {
|
return database().transaction(() => {
|
||||||
|
requireHost(hostId);
|
||||||
const operation = getHostOperation(id);
|
const operation = getHostOperation(id);
|
||||||
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
|
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.');
|
if (!['accepted','running','completed','failed','unknown'].includes(input.state as string)) throw new Error('Invalid operation state.');
|
||||||
|
|||||||
@@ -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,
|
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 '',
|
bind_address TEXT NOT NULL DEFAULT '0.0.0.0', public_address TEXT NOT NULL DEFAULT '',
|
||||||
minimum_free_bytes INTEGER NOT NULL DEFAULT 1073741824,
|
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 (
|
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,
|
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 }[];
|
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');
|
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);
|
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>
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
||||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||||
type Event = { request: Request; params: { id: string } };
|
type Event = { request: Request; params: { id: string } };
|
||||||
export function GET({ request, params }: Event) {
|
export function GET({ request, params }: Event) {
|
||||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||||
// Payloads and results can contain RCON keys or staged credentials. History
|
// Payloads and results can contain RCON keys or staged credentials. History
|
||||||
// exposes metadata only; agent APIs remain the only command delivery path.
|
// exposes metadata only; agent APIs remain the only command delivery path.
|
||||||
return json({ host, operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
return json({ host, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||||
}
|
}
|
||||||
export async function PATCH({ request, params }: Event) {
|
export async function PATCH({ request, params }: Event) {
|
||||||
requireManagementOrigin(request);
|
requireManagementOrigin(request);
|
||||||
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
|
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DELETE({ request, params }: Event) {
|
||||||
|
requireManagementOrigin(request);
|
||||||
|
try { deleteHost(params.id); return json({ deleted: true }); }
|
||||||
|
catch (cause) {
|
||||||
|
if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status });
|
||||||
|
return hostApiError(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,14 +4,24 @@
|
|||||||
import type { Host } from '$lib/hosts.js';
|
import type { Host } from '$lib/hosts.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||||
let hosts = $state<Host[]>([]);
|
let hosts = $state<Host[]>([]);
|
||||||
let loading = $state(true); let busy = $state(false); let error = $state('');
|
let loading = $state(true); let busy = $state(false); let error = $state('');
|
||||||
|
let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null);
|
||||||
|
let loadSequence = 0;
|
||||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
|
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
|
||||||
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
|
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
|
||||||
const canManage = $derived($page.data.user?.role === 'admin');
|
const canManage = $derived($page.data.user?.role === 'admin');
|
||||||
async function load() {
|
async function load() {
|
||||||
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); hosts = body.hosts; }
|
const sequence = ++loadSequence;
|
||||||
catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } }
|
||||||
|
catch (cause) { if (sequence === loadSequence) error = (cause as Error).message; } finally { if (sequence === loadSequence) loading = false; }
|
||||||
|
}
|
||||||
|
async function deleted() {
|
||||||
|
notice = `${deletingHost?.name ?? 'Host'} deleted.`;
|
||||||
|
if (enrollment?.hostId === deletingHost?.id) enrollment = null;
|
||||||
|
deletingHost = null;
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
async function add(event: SubmitEvent) {
|
async function add(event: SubmitEvent) {
|
||||||
event.preventDefault(); busy = true; error = ''; enrollment = null;
|
event.preventDefault(); busy = true; error = ''; enrollment = null;
|
||||||
@@ -28,6 +38,7 @@
|
|||||||
<section class="page">
|
<section class="page">
|
||||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
|
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
|
||||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||||
|
{#if notice}<p role="status">{notice}</p>{/if}
|
||||||
{#if canManage}<section class="panel">
|
{#if canManage}<section class="panel">
|
||||||
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
|
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
|
||||||
<form onsubmit={add}><fieldset disabled={busy}>
|
<form onsubmit={add}><fieldset disabled={busy}>
|
||||||
@@ -39,14 +50,15 @@
|
|||||||
{#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}
|
{#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}
|
</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}
|
{#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}<a class="panel host" href={`/hosts/${host.id}`}><div class="row"><h2>{host.name}</h2><span class:ready={host.readiness === 'ready'} class="state">{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.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}</a>{/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">{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.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>Manage host</Button>{#if canManage}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>Delete host</Button>{/if}</div></article>{/each}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
|
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
|
||||||
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); }
|
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); }
|
||||||
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; }
|
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; }
|
||||||
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host { text-decoration:none; color:inherit; } .host:hover { border-color:var(--accent-primary); } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
|
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host h2 a { color:inherit; text-decoration:none; } .host h2 a:hover { color:var(--accent-primary); } .host-actions { display:flex; flex-wrap:wrap; gap:12px; margin-top:20px; } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
|
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
||||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||||
|
let deleteOpen = $state(false);
|
||||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
||||||
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
||||||
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
||||||
@@ -28,7 +31,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history." />
|
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history.">
|
||||||
|
{#snippet actions()}{#if canManage && host}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>Delete host</Button>{/if}{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {host.readiness}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
<section class="panel"><div class="row"><h2>{host.connectionState} · {host.readiness}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
|
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={() => goto('/hosts')} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('management route permissions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]/enrollment', 'POST'],
|
['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]', 'DELETE'], ['/api/hosts/[id]/enrollment', 'POST'],
|
||||||
['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'],
|
['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'],
|
||||||
['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'],
|
['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'],
|
||||||
['/api/game-files/[id]/download', 'POST'], ['/api/game-files/[id]/upload/files/[...path]', 'PUT'],
|
['/api/game-files/[id]/download', 'POST'], ['/api/game-files/[id]/upload/files/[...path]', 'PUT'],
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
const inventory = { hostname: 'games-1', protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3 };
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => { vi.unstubAllEnvs(); await fixture.cleanup(); });
|
||||||
|
|
||||||
|
async function enrolled() {
|
||||||
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const host = hosts.createHost({ name: 'Game host' });
|
||||||
|
const credentials = hosts.enrollHost(hosts.createEnrollment(host.id).token, inventory);
|
||||||
|
return { ...hosts, host, credentials };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instance(hostId: string, retired = false) {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at,retired_at)
|
||||||
|
VALUES('instance','t5_zombie_theater_1',?,'library','plutainer:test',28960,?,?)`)
|
||||||
|
.run(hostId, new Date().toISOString(), retired ? new Date().toISOString() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('host deletion', () => {
|
||||||
|
it('adds the deletion marker to existing host schemas without losing hosts', async () => {
|
||||||
|
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
try {
|
||||||
|
legacy.exec("CREATE TABLE ec_hosts(id TEXT PRIMARY KEY, name TEXT NOT NULL); INSERT INTO ec_hosts VALUES('old','Existing host')");
|
||||||
|
ensureHostSchema(legacy); ensureHostSchema(legacy);
|
||||||
|
expect(legacy.prepare('SELECT * FROM ec_hosts').get()).toEqual({ id: 'old', name: 'Existing host', deleted_at: null });
|
||||||
|
} finally { legacy.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an unenrolled host and invalidates its unused enrollment', async () => {
|
||||||
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const host = hosts.createHost({ name: 'Not installed' });
|
||||||
|
const enrollment = hosts.createEnrollment(host.id);
|
||||||
|
expect(hosts.hostDeletionStatus(host.id)).toEqual({ allowed: true, blockers: [] });
|
||||||
|
hosts.deleteHost(host.id);
|
||||||
|
expect(hosts.getHost(host.id)).toBeNull(); expect(hosts.listHosts()).toEqual([]);
|
||||||
|
expect(() => hosts.enrollHost(enrollment.token, inventory)).toThrow('invalid or expired');
|
||||||
|
expect(() => hosts.createEnrollment(host.id)).toThrow('not found');
|
||||||
|
expect(() => hosts.deleteHost(host.id)).toThrow(expect.objectContaining({ status: 404 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an enrolled empty host, revokes credentials, and preserves files and history', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
const other = hosts.createHost({ name: 'Keep' });
|
||||||
|
const otherCredentials = hosts.enrollHost(hosts.createEnrollment(other.id).token, inventory);
|
||||||
|
const enrollment = hosts.createEnrollment(hosts.host.id);
|
||||||
|
const completed = hosts.queueHostOperation(hosts.host.id, 'host.inspect', {});
|
||||||
|
hosts.reportHostOperation(hosts.host.id, completed.id, { state: 'completed', result: { hostname: 'games-1' } });
|
||||||
|
await instance(hosts.host.id, true);
|
||||||
|
const file = join(fixture.root, 'game-files-sentinel');
|
||||||
|
await writeFile(file, 'Game files remain');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
|
||||||
|
ensureGameFileSchema(db());
|
||||||
|
db().prepare("INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES('library','T5','t5','',?)").run(new Date().toISOString());
|
||||||
|
db().prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
|
||||||
|
VALUES('version','library','1','','[]','ready',?,?,?,'t5_game_files')`).run(new Date().toISOString(), file, hosts.host.id);
|
||||||
|
db().prepare("INSERT INTO host_library_caches(host_id,version_id,status,updated_at) VALUES(?,'version','ready',?)").run(hosts.host.id, new Date().toISOString());
|
||||||
|
|
||||||
|
hosts.deleteHost(hosts.host.id);
|
||||||
|
|
||||||
|
expect(hosts.authenticateCredential(hosts.credentials.credential)).toBeNull();
|
||||||
|
expect(hosts.authenticateCredential(otherCredentials.credential)?.id).toBe(other.id);
|
||||||
|
expect(() => hosts.enrollHost(enrollment.token, inventory)).toThrow('invalid or expired');
|
||||||
|
expect(hosts.listHosts().map(host => host.id)).toEqual([other.id]);
|
||||||
|
expect(hosts.getHostOperation(completed.id)).toMatchObject({ state: 'completed', result: { hostname: 'games-1' } });
|
||||||
|
expect(hosts.listHostOperations(hosts.host.id)).toHaveLength(1);
|
||||||
|
expect(db().prepare('SELECT mode,deleted_at FROM ec_hosts WHERE id=?').get(hosts.host.id)).toEqual({ mode: 'disabled', deleted_at: expect.any(String) });
|
||||||
|
expect(db().prepare('SELECT revoked_at FROM ec_host_credentials WHERE host_id=?').get(hosts.host.id)).toEqual({ revoked_at: expect.any(String) });
|
||||||
|
expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 1 });
|
||||||
|
expect(db().prepare('SELECT COUNT(*) AS count FROM host_library_caches').get()).toEqual({ count: 1 });
|
||||||
|
expect(db().prepare("SELECT state,source_host_id FROM game_file_versions WHERE id='version'").get()).toEqual({ state: 'ready', source_host_id: hosts.host.id });
|
||||||
|
expect(await readFile(file, 'utf8')).toBe('Game files remain');
|
||||||
|
expect(() => hosts.heartbeat(hosts.host.id, inventory)).toThrow('not found');
|
||||||
|
expect(() => hosts.rotateCredential(hosts.host.id)).toThrow('not found');
|
||||||
|
expect(() => hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {})).toThrow('not found');
|
||||||
|
expect(() => hosts.reportHostOperation(hosts.host.id, completed.id, { state: 'running' })).toThrow('not found');
|
||||||
|
expect(() => hosts.updateHost(hosts.host.id, { mode: 'active' })).toThrow('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels only undelivered queued operations and retains their journal', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
const operations = ['runtime.pull', 'library.download', 'credentials.rotate'].map(kind => hosts.queueHostOperation(hosts.host.id, kind, {}));
|
||||||
|
expect(hosts.hostDeletionStatus(hosts.host.id).allowed).toBe(true);
|
||||||
|
hosts.deleteHost(hosts.host.id);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const operation of operations) {
|
||||||
|
expect(hosts.getHostOperation(operation.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('before this operation was delivered') });
|
||||||
|
expect(db().prepare('SELECT state FROM ec_host_operation_events WHERE operation_id=? ORDER BY id').all(operation.id)).toEqual([{ state: 'pending' }, { state: 'failed' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks live instances even when no server record or port reservation exists', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
await instance(hosts.host.id);
|
||||||
|
const status = hosts.hostDeletionStatus(hosts.host.id);
|
||||||
|
expect(status.allowed).toBe(false); expect(status.blockers.join(' ')).toContain('t5_zombie_theater_1');
|
||||||
|
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||||
|
expect(hosts.authenticateCredential(hosts.credentials.credential)?.id).toBe(hosts.host.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks residual port reservations independently of live instances', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
hosts.reserveHostPort(hosts.host.id, 'unresolved-instance');
|
||||||
|
expect(hosts.hostDeletionStatus(hosts.host.id)).toMatchObject({ allowed: false, blockers: [expect.stringContaining('reserved game port')] });
|
||||||
|
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||||
|
hosts.releaseHostPorts('unresolved-instance');
|
||||||
|
hosts.deleteHost(hosts.host.id);
|
||||||
|
expect(hosts.getHost(hosts.host.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['pending', 'accepted', 'running', 'unknown'] as const)('blocks unresolved %s work after delivery without revoking access or cancelling other work', async state => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
const operation = hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {});
|
||||||
|
hosts.pendingHostOperations(hosts.host.id);
|
||||||
|
if (state !== 'pending') hosts.reportHostOperation(hosts.host.id, operation.id, { state });
|
||||||
|
const queued = hosts.queueHostOperation(hosts.host.id, 'library.download', {});
|
||||||
|
hosts.updateHost(hosts.host.id, { mode: 'disabled' });
|
||||||
|
expect(hosts.hostDeletionStatus(hosts.host.id)).toMatchObject({ allowed: false, blockers: [expect.stringContaining('delivered operation')] });
|
||||||
|
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||||
|
expect(hosts.getHostOperation(queued.id)?.state).toBe('pending');
|
||||||
|
expect(hosts.authenticateCredential(hosts.credentials.credential)?.id).toBe(hosts.host.id);
|
||||||
|
hosts.reportHostOperation(hosts.host.id, operation.id, { state: 'completed' });
|
||||||
|
hosts.deleteHost(hosts.host.id);
|
||||||
|
expect(hosts.getHostOperation(queued.id)?.state).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechecks blockers on DELETE even after a successful eligibility check', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
const route = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||||
|
const url = `https://ec.test/api/hosts/${hosts.host.id}`;
|
||||||
|
const params = { id: hosts.host.id };
|
||||||
|
expect(await route.GET({ request: new Request(url), params }).json()).toMatchObject({ deletion: { allowed: true, blockers: [] } });
|
||||||
|
const operation = hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {});
|
||||||
|
hosts.pendingHostOperations(hosts.host.id);
|
||||||
|
const blocked = route.DELETE({ request: new Request(url, { method: 'DELETE' }), params });
|
||||||
|
expect(blocked.status).toBe(409);
|
||||||
|
expect(await blocked.json()).toMatchObject({ error: expect.any(String), blockers: [expect.stringContaining('delivered operation')] });
|
||||||
|
hosts.reportHostOperation(hosts.host.id, operation.id, { state: 'completed' });
|
||||||
|
const removed = route.DELETE({ request: new Request(url, { method: 'DELETE' }), params });
|
||||||
|
expect(removed.status).toBe(200); expect(await removed.json()).toEqual({ deleted: true });
|
||||||
|
expect(route.DELETE({ request: new Request(url, { method: 'DELETE' }), params }).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps DELETE restricted to admin sessions and same-origin management requests', async () => {
|
||||||
|
const hosts = await enrolled();
|
||||||
|
const { routePermission, roleAllows } = await import('../../src/lib/server/auth/access.js');
|
||||||
|
const permission = routePermission('/api/hosts/[id]', 'DELETE');
|
||||||
|
expect(roleAllows('admin', permission)).toBe(true);
|
||||||
|
expect(roleAllows('operator', permission)).toBe(false); expect(roleAllows('viewer', permission)).toBe(false);
|
||||||
|
const { DELETE } = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||||
|
for (const headers of [{ origin: 'https://attacker.test' }, { authorization: `Bearer ${hosts.credentials.credential}` }]) {
|
||||||
|
expect(() => DELETE({ request: new Request('https://ec.test/api/hosts/id', { method: 'DELETE', headers }), params: { id: hosts.host.id } })).toThrow(expect.objectContaining({ status: 403 }));
|
||||||
|
}
|
||||||
|
expect(hosts.getHost(hosts.host.id)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user