Add House Party Protocol for fleet-wide queued destruction

This commit is contained in:
2026-09-18 00:39:19 -04:00
parent 586133c711
commit 274aedb66e
7 changed files with 212 additions and 3 deletions
+8
View File
@@ -70,6 +70,14 @@ the sweep marks pending rows `manual` so nothing hangs forever.
## Deployment blueprints and launch previews
Deployments also offers **House Party Protocol**: review the entire fleet and queue
destruction in one action. This includes queued creations and reuses pending
destruction requests. Each server waits for a fresh zero-player reading; occupied
or unreachable servers remain queued. Containers, server files, and registrations
are removed, while saved keys and blueprints are retained. The preview must be
reviewed again if fleet membership changes before confirmation. Servers created
after confirmation are not included. Progress appears in deployment history.
Deployments includes a blueprint builder for whole server collections: choose
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet**
counts existing and queued servers and creates only the shortfall. The preview
+11 -3
View File
@@ -416,12 +416,16 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
const destroying = new Set<string>();
// Persist the request before returning so navigation and client disconnects cannot lose it.
export function destroyServer(name: string): DestroyServerResult & { deploymentId?: string } {
function hasActiveCreation(name: string): boolean {
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
}
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
const operation = db().transaction(() => {
const id = beginDeployment(name, 'destroy');
const id = beginDeployment(name, 'destroy', triggeredBy);
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
deploymentStage(id, 'Waiting to verify the server is empty');
return id;
@@ -443,6 +447,10 @@ async function runDestructions(): Promise<void> {
destroying.add(operation.server);
try {
if (!getServer(operation.server)) {
if (hasActiveCreation(operation.server)) {
deploymentStage(operation.id, 'Waiting for server creation to finish');
continue;
}
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
continue;
}
+28
View File
@@ -0,0 +1,28 @@
import { db } from '../db/index.js';
import { destroyServer } from './create.js';
export function housePartyTargets(): string[] {
return (db().prepare(`
SELECT name FROM servers WHERE destroyed_at IS NULL
UNION
SELECT server AS name FROM server_deployments
WHERE action = 'create' AND state IN ('queued', 'running', 'awaiting-health')
ORDER BY name
`).all() as { name: string }[]).map(row => row.name);
}
export function queueHouseParty(expectedServers: string[]) {
return db().transaction(() => {
const servers = housePartyTargets();
const expected = [...expectedServers].sort();
if (servers.length !== expected.length || servers.some((name, index) => name !== expected[index])) {
return { ok: false as const, error: 'The fleet changed. Refresh the preview and review the servers again.' };
}
const deployments = servers.map(server => {
const result = destroyServer(server, 'house-party-protocol');
if (!result.ok) throw new Error(result.message);
return { server, deploymentId: result.deploymentId! };
});
return { ok: true as const, deployments };
})();
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let servers = $state<string[] | null>(null);
let busy = $state(false);
let error = $state('');
let wasOpen = false;
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; dialog.close(); }
});
async function review() {
busy = true; error = ''; servers = null;
try {
const res = await fetch('/api/deployments/house-party');
if (!res.ok) throw new Error('Could not load the fleet.');
servers = (await res.json()).servers;
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function launch() {
if (busy || !servers?.length) return;
busy = true; error = '';
try {
const res = await fetch('/api/deployments/house-party', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const body = await res.json();
if (!res.ok) {
if (res.status === 409) servers = null;
throw new Error(body.error ?? 'Could not queue destruction.');
}
open = false;
onqueued(body.deployments.length);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-label="House Party Protocol" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="content">
<header><span class="eyebrow">Call it a night</span><h2>House Party Protocol</h2></header>
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
{#if servers.length}<ul>{#each servers as server}<li>{server}</li>{/each}</ul><p class="caption">Existing destruction requests are reused. Servers added after this request are not included.</p>{/if}
{:else}<p>{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}</p>{/if}
<div class="actions">
<Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button>
{#if !servers}<Button disabled={busy} onclick={review}>Refresh preview</Button>
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Queue destruction of {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
</div>
</div>
</dialog>
<style>
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
.eyebrow { display: block; color: var(--state-danger); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: 8px; }
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>
@@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import { housePartyTargets, queueHouseParty } from '$lib/server/fleet/house-party.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function GET() {
return json({ servers: housePartyTargets() }, { headers: { 'Cache-Control': 'no-store' } });
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string')) {
return json({ error: 'A reviewed server list is required.' }, { status: 400 });
}
const result = queueHouseParty(body.servers);
if (!result.ok) return json(result, { status: 409 });
void kick().catch(err => console.error('[house-party] worker kick failed:', err));
return json(result, { status: 202 });
}
+11
View File
@@ -7,6 +7,7 @@
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
interface Deployment {
@@ -23,6 +24,8 @@
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let housePartyOpen = $state(false);
let housePartyNotice = $state('');
let collections = $state<CollectionSummary[]>([]);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
@@ -92,6 +95,12 @@
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<div class="fleet-shutdown">
<div><strong>Call it a night.</strong><p>Queue the whole fleet for removal once each server is empty.</p></div>
<Button variant="danger" onclick={() => housePartyOpen = true}>House Party Protocol</Button>
</div>
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in In flight below.`; void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
@@ -171,6 +180,8 @@
{/snippet}
<style>
.fleet-shutdown { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: 12px; }
.fleet-shutdown p { margin: 5px 0 0; font-size: 13px; color: var(--text-secondary); }
.page { display: flex; flex-direction: column; gap: 28px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; }
+62
View File
@@ -41,6 +41,68 @@ afterEach(async () => {
vi.unstubAllEnvs();
});
describe('House Party Protocol', () => {
it('includes registered and queued servers once and reuses destruction requests', async () => {
const { createServer, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { housePartyTargets, queueHouseParty } = await import('../../src/lib/server/fleet/house-party.js');
expect(await createServer(input)).toMatchObject({ ok: true });
expect(queueCreateServer(input)).toMatchObject({ ok: true });
const names = housePartyTargets();
expect(names).toEqual([serverName, 't5_zombie_cosmodrome_2']);
const first = queueHouseParty(names);
expect(first).toMatchObject({ ok: true, deployments: [{ server: serverName }, { server: names[1] }] });
expect(queueHouseParty(names)).toEqual(first);
});
it('rejects a changed fleet without queuing any destruction', async () => {
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { housePartyTargets, queueHouseParty } = await import('../../src/lib/server/fleet/house-party.js');
const { db } = await import('../../src/lib/server/db/index.js');
const names = housePartyTargets();
expect(queueCreateServer(input).ok).toBe(true);
expect(queueHouseParty(names).ok).toBe(false);
expect(db().prepare("SELECT * FROM server_deployments WHERE action = 'destroy'").all()).toEqual([]);
});
it('rolls back the entire bulk request when an insert fails', async () => {
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const { housePartyTargets, queueHouseParty } = await import('../../src/lib/server/fleet/house-party.js');
const { db } = await import('../../src/lib/server/db/index.js');
expect(queueCreateServer(input).ok).toBe(true);
expect(queueCreateServer(input).ok).toBe(true);
db().exec(`CREATE TRIGGER reject_destroy BEFORE INSERT ON server_deployments
WHEN NEW.action = 'destroy' AND NEW.server = 't5_zombie_cosmodrome_2'
BEGIN SELECT RAISE(ABORT, 'test failure'); END`);
expect(() => queueHouseParty(housePartyTargets())).toThrow('test failure');
expect(db().prepare("SELECT * FROM server_deployments WHERE action = 'destroy'").all()).toEqual([]);
});
it('waits for queued creations, then for verified emptiness, and preserves keys', async () => {
const { queueCreateServer, processCreations, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { housePartyTargets, queueHouseParty } = await import('../../src/lib/server/fleet/house-party.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const { komodoDestroyDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
expect(queueCreateServer(input).ok).toBe(true);
expect(queueHouseParty(housePartyTargets()).ok).toBe(true);
await processDestructions();
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
await processCreations();
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(2);
await processDestructions();
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(null);
await processDestructions();
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(0);
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never);
await processDestructions();
expect(komodoDestroyDeployment).toHaveBeenCalledWith(serverName);
expect(getServer(serverName)).toBeUndefined();
expect(getServerKey(serverName)).toBe('test-key');
});
});
async function promotedConfig() {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');