Add House Party Protocol for fleet-wide queued destruction
This commit is contained in:
@@ -70,6 +70,14 @@ the sweep marks pending rows `manual` so nothing hangs forever.
|
|||||||
|
|
||||||
## Deployment blueprints and launch previews
|
## 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
|
Deployments includes a blueprint builder for whole server collections: choose
|
||||||
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet**
|
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
|
counts existing and queued servers and creates only the shortfall. The preview
|
||||||
|
|||||||
@@ -416,12 +416,16 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
|
|||||||
const destroying = new Set<string>();
|
const destroying = new Set<string>();
|
||||||
|
|
||||||
// Persist the request before returning so navigation and client disconnects cannot lose it.
|
// 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;
|
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 (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 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);
|
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||||
deploymentStage(id, 'Waiting to verify the server is empty');
|
deploymentStage(id, 'Waiting to verify the server is empty');
|
||||||
return id;
|
return id;
|
||||||
@@ -443,6 +447,10 @@ async function runDestructions(): Promise<void> {
|
|||||||
destroying.add(operation.server);
|
destroying.add(operation.server);
|
||||||
try {
|
try {
|
||||||
if (!getServer(operation.server)) {
|
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' });
|
finishDeployment(operation.id, { ok: false, message: 'Server is no longer registered' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||||
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
|
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
|
||||||
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
|
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
|
||||||
|
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
|
||||||
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
|
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
|
||||||
|
|
||||||
interface Deployment {
|
interface Deployment {
|
||||||
@@ -23,6 +24,8 @@
|
|||||||
let builderOpen = $state(false);
|
let builderOpen = $state(false);
|
||||||
let editingBlueprint = $state<BlueprintSummary | null>(null);
|
let editingBlueprint = $state<BlueprintSummary | null>(null);
|
||||||
let collectionOpen = $state(false);
|
let collectionOpen = $state(false);
|
||||||
|
let housePartyOpen = $state(false);
|
||||||
|
let housePartyNotice = $state('');
|
||||||
let collections = $state<CollectionSummary[]>([]);
|
let collections = $state<CollectionSummary[]>([]);
|
||||||
let blueprints = $state<BlueprintSummary[]>([]);
|
let blueprints = $state<BlueprintSummary[]>([]);
|
||||||
let blueprintsLoading = $state(true);
|
let blueprintsLoading = $state(true);
|
||||||
@@ -92,6 +95,12 @@
|
|||||||
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
||||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
<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>
|
<section>
|
||||||
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
||||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||||
@@ -171,6 +180,8 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<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; }
|
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
.row-head { justify-content: space-between; }
|
.row-head { justify-content: space-between; }
|
||||||
|
|||||||
@@ -41,6 +41,68 @@ afterEach(async () => {
|
|||||||
vi.unstubAllEnvs();
|
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() {
|
async function promotedConfig() {
|
||||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
|||||||
Reference in New Issue
Block a user