Allow deleting settled autoscaling policies

This commit is contained in:
2026-10-06 23:41:37 -04:00
parent 285279ee6d
commit 34648b34e0
7 changed files with 131 additions and 5 deletions
+7 -1
View File
@@ -41,6 +41,12 @@ The authorization claim is the cancellation boundary. A pause after that point c
Turning a policy Off does not recreate lobbies it removed or remove lobbies it created. Once operations settle, use Collections to restore a fixed lineup through the normal capacity and empty-server gates. Turning a policy Off does not recreate lobbies it removed or remove lobbies it created. Once operations settle, use Collections to restore a fixed lineup through the normal capacity and empty-server gates.
## Delete a policy
Choose **Delete policy** and confirm to remove its settings, decisions, and measurements. Existing lobbies, saved keys, and Provisioning history are kept. Deleting a live policy releases ownership without changing the lineup, and a new policy can reuse the same scope.
Deletion requires the current policy revision and no unsettled autoscaling operation. If work is outstanding, turn the policy Off and wait for its operation to settle before deleting. Delivered or uncertain host commands retain their policy and admission evidence until reconciliation finishes.
## Dry run and pilot ## Dry run and pilot
The production observation period and live pilot have not been run as part of implementation. Begin them on the operator-selected maps after deploying the controller and compatible agents. The production observation period and live pilot have not been run as part of implementation. Begin them on the operator-selected maps after deploying the controller and compatible agents.
@@ -59,6 +65,6 @@ Measurement totals exclude collection gaps. Request-to-verified-readiness summar
`GET /api/autoscaling` returns policies, current evidence, recent decisions, measurements, and global pause state. Viewers may read it; mutations require an operator or administrator, using the existing authentication and origin checks. `GET /api/autoscaling` returns policies, current evidence, recent decisions, measurements, and global pause state. Viewers may read it; mutations require an operator or administrator, using the existing authentication and origin checks.
`POST /api/autoscaling` accepts `preview`, `save`, or `global-pause`. Preview/save requests contain `settings`, plus `id` and `revision` when editing. Use `mapSlug: '*'` for every map in the selected game; existing map-specific requests are unchanged. Scope cannot change when editing. Save also requires the preview `token`. Global pause uses a boolean `paused` field. Agent removal claims use a separate host-authenticated endpoint and are bound to the exact command, host, instance generation, policy revision, and expiry. `POST /api/autoscaling` accepts `preview`, `save`, `delete`, or `global-pause`. Delete requires `id` and the current `revision`. Preview/save requests contain `settings`, plus `id` and `revision` when editing. Use `mapSlug: '*'` for every map in the selected game; existing map-specific requests are unchanged. Scope cannot change when editing. Save also requires the preview `token`. Global pause uses a boolean `paused` field. Agent removal claims use a separate host-authenticated endpoint and are bound to the exact command, host, instance generation, policy revision, and expiry.
Shared contracts live in `src/lib/autoscaling.ts`; the evaluator, snapshots, persistence, ownership, and authorization live in `src/lib/server/autoscaling/`. Provisioning remains the only lifecycle executor. See the [approved implementation plan](lobby-autoscaling-plan.md) for the design and rollout criteria. Shared contracts live in `src/lib/autoscaling.ts`; the evaluator, snapshots, persistence, ownership, and authorization live in `src/lib/server/autoscaling/`. Provisioning remains the only lifecycle executor. See the [approved implementation plan](lobby-autoscaling-plan.md) for the design and rollout criteria.
+18
View File
@@ -75,6 +75,24 @@ export function saveScaling(input: unknown, id?: string, revision?: number, toke
})(); })();
} }
export function deleteScaling(id: string, revision: number): void {
db().transaction(() => {
const policy = getScalingPolicy(id);
if (!policy) throw new ScalingError('Policy not found.', 404);
if (policy.revision !== revision) throw new ScalingError('Policy changed. Refresh before deleting.', 409);
// Keep admission evidence and ownership while a host command may still act.
if (db().prepare('SELECT 1 FROM autoscaling_decisions WHERE policy_id=? AND resolved=0').get(id)) {
throw new ScalingError('Pause this policy and settle its outstanding Provisioning work before deleting it.', 409);
}
revokePolicyRemovals(id);
db().prepare('DELETE FROM autoscaling_authorizations WHERE policy_id=?').run(id);
db().prepare('DELETE FROM autoscaling_members WHERE policy_id=?').run(id);
db().prepare('DELETE FROM autoscaling_samples WHERE policy_id=?').run(id);
db().prepare('DELETE FROM autoscaling_decisions WHERE policy_id=?').run(id);
db().prepare('DELETE FROM autoscaling_policies WHERE id=?').run(id);
})();
}
export function setScalingGlobalPause(paused: boolean): void { export function setScalingGlobalPause(paused: boolean): void {
db().transaction(() => { db().transaction(() => {
db().prepare("INSERT INTO settings(key,value) VALUES('autoscaling_paused',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(paused ? '1' : '0'); db().prepare("INSERT INTO settings(key,value) VALUES('autoscaling_paused',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(paused ? '1' : '0');
+4 -1
View File
@@ -60,7 +60,10 @@ function warningMessages(raw: string): string[] {
function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource { function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource {
const id = source.source_id; const id = source.source_id;
if (source.kind === 'autoscaling') return { ...source, label: 'Autoscaling policy', href: `/autoscaling#${encodeURIComponent(id)}` }; if (source.kind === 'autoscaling') {
const exists = !!db().prepare('SELECT 1 FROM autoscaling_policies WHERE id=?').get(id);
return { ...source, label: exists ? 'Autoscaling policy' : 'Deleted autoscaling policy', href: exists ? `/autoscaling#${encodeURIComponent(id)}` : null };
}
if (source.kind === 'campaign') { if (source.kind === 'campaign') {
const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d
LEFT JOIN scripts s ON s.id=COALESCE(d.script_id, (SELECT script_id FROM deployment_scripts WHERE deployment_id=d.id ORDER BY script_id LIMIT 1)) LEFT JOIN scripts s ON s.id=COALESCE(d.script_id, (SELECT script_id FROM deployment_scripts WHERE deployment_id=d.id ORDER BY script_id LIMIT 1))
+6 -1
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { previewScaling, saveScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js'; import { previewScaling, saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
export function GET() { return json(scalingReport()); } export function GET() { return json(scalingReport()); }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
@@ -9,6 +9,11 @@ export async function POST({ request }: { request: Request }) {
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.'); if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
if (body.action === 'preview') return json(previewScaling(body.settings, body.id, body.revision)); if (body.action === 'preview') return json(previewScaling(body.settings, body.id, body.revision));
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision, body.token) }); if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision, body.token) });
if (body.action === 'delete') {
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
deleteScaling(body.id, body.revision);
return json({ ok: true });
}
if (body.action === 'global-pause' && typeof body.paused === 'boolean') { setScalingGlobalPause(body.paused); return json({ ok: true }); } if (body.action === 'global-pause' && typeof body.paused === 'boolean') { setScalingGlobalPause(body.paused); return json({ ok: true }); }
throw new ScalingError('Choose a supported policy action.'); throw new ScalingError('Choose a supported policy action.');
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); } } catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); }
+16 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -48,6 +49,19 @@
} catch (error) { actionError = (error as Error).message; preview = null; } } catch (error) { actionError = (error as Error).message; preview = null; }
finally { busy = false; } finally { busy = false; }
} }
async function remove(policy: ScalingPolicy) {
const scope = policy.mapSlug === '*' ? `every map in ${policy.game.toUpperCase()}` : `${zombieMapBySlug(policy.game, policy.mapSlug)?.friendly ?? policy.mapSlug} · ${policy.game.toUpperCase()}`;
if (!await confirmAction({ title: 'Delete autoscaling policy?', message: `Delete the policy for ${scope} and its scaling history? Existing lobbies, saved keys, and Provisioning history will be kept.`, confirmLabel: 'Delete policy', danger: true })) return;
busy = true; actionError = ''; notice = ''; ++generation;
try {
await post({ action: 'delete', id: policy.id, revision: policy.revision });
if (editing?.id === policy.id) { editing = null; editorOpen = false; preview = null; }
snapshot = { ...report, policies: report.policies.filter(view => view.policy.id !== policy.id) };
notice = 'Policy deleted.';
await refresh();
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
async function pauseAll() { async function pauseAll() {
busy = true; actionError = ''; ++generation; busy = true; actionError = ''; ++generation;
try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Review any unsettled operations below.'; await refresh(); } try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Review any unsettled operations below.'; await refresh(); }
@@ -102,9 +116,9 @@
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to observe a map or a whole game in dry run.</p>{/if} {#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to observe a map or a whole game in dry run.</p>{/if}
{#each report.policies as view (view.policy.id)} {#each report.policies as view (view.policy.id)}
<section class="panel" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}> <section class="panel" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
<div class="heading"><h2 id={`title-${view.policy.id}`}>{view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}</h2><span>{modeLabel(view.policy.mode)}</span>{#if canOperate}<Button disabled={busy} onclick={() => edit(view.policy)}>Edit or pause</Button>{/if}</div> <div class="heading"><h2 id={`title-${view.policy.id}`}>{view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}</h2><span>{modeLabel(view.policy.mode)}</span>{#if canOperate}<Button disabled={busy} onclick={() => edit(view.policy)}>Edit or pause</Button><Button variant="danger" disabled={busy || view.unsettled.length > 0} title={view.unsettled.length ? 'Pause the policy and settle outstanding work before deleting it.' : undefined} onclick={() => remove(view.policy)}>Delete policy</Button>{/if}</div>
<p class="counts">{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped</p> <p class="counts">{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped</p>
{#if view.unsettled.length}<div class="banner"><strong>Work is still settling.</strong>{#each view.unsettled as operation}<p><a href={`/provisioning/${operation.id}`}>{operation.action} · {operation.state}</a> — {operation.stage}</p>{/each}{#if view.ownsMap}<p>This policy retains ownership of its scope until its outstanding work settles.</p>{/if}</div>{/if} {#if view.unsettled.length}<div class="banner"><strong>Work is still settling.</strong><p>Pause this policy and settle its outstanding work before deleting it.</p>{#each view.unsettled as operation}<p><a href={`/provisioning/${operation.id}`}>{operation.action} · {operation.state}</a> — {operation.stage}</p>{/each}{#if view.ownsMap}<p>This policy retains ownership of its scope until its outstanding work settles.</p>{/if}</div>{/if}
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyedNames.length} saved keys</p> <p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyedNames.length} saved keys</p>
<p><strong>{view.policy.mode === 'dry-run' ? 'Hypothetical decision: ' : 'Current decision: '}</strong>{view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}</p> <p><strong>{view.policy.mode === 'dry-run' ? 'Hypothetical decision: ' : 'Current decision: '}</strong>{view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}</p>
{#if view.policy.mode === 'create-only' && view.decision.action === 'destroy'}<p>Removal is advisory in Create only mode.</p>{/if} {#if view.policy.mode === 'create-only' && view.decision.action === 'destroy'}<p>Removal is advisory in Create only mode.</p>{/if}
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const settings = { game: 't6', mapSlug: 'town', maximum: 1, mode: 'dry-run' };
async function setup(mode = 'dry-run') {
if (mode.startsWith('create')) await prepareNativeHost();
const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run('t6_zombie_town_1', 'test-key');
const input = { ...settings, mode };
const saved = scaling.saveScaling(input, undefined, undefined, scaling.previewScaling(input).token);
return { db, scaling, saved, input };
}
it('deletes policy evidence atomically while preserving lobbies, keys, and unrelated policies', async () => {
const { db, scaling, saved } = await setup();
db().prepare("INSERT INTO servers(name,game,map_slug,map) VALUES('t6_zombie_town_1','t6','town','zm_transit')").run();
await scaling.runAutoscaling();
db().prepare("INSERT INTO server_keys(server_id,pluto_key) VALUES('t5_zombie_theater_1','other-key')").run();
const otherSettings = { ...settings, game: 't5', mapSlug: 'theater', mode: 'off' };
const other = scaling.saveScaling(otherSettings, undefined, undefined, scaling.previewScaling(otherSettings).token);
for (const table of ['autoscaling_decisions', 'autoscaling_members', 'autoscaling_samples']) expect(db().prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE policy_id=?`).get(saved.id)).toEqual({ n: 1 });
scaling.deleteScaling(saved.id, saved.revision);
expect(scaling.getScalingPolicy(saved.id)).toBeUndefined();
expect(scaling.listScalingPolicies().map(p => p.id)).toEqual([other.id]);
for (const table of ['autoscaling_decisions', 'autoscaling_members', 'autoscaling_samples', 'autoscaling_authorizations']) expect(db().prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE policy_id=?`).get(saved.id)).toEqual({ n: 0 });
expect(db().prepare('SELECT name FROM servers').all()).toEqual([{ name: 't6_zombie_town_1' }]);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_keys').get()).toEqual({ n: 2 });
expect(db().prepare('PRAGMA foreign_key_check').all()).toEqual([]);
expect(scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token).id).not.toBe(saved.id);
});
it.each(['off', 'create-only', 'create-destroy'])('deletes a settled %s policy even after key loss and releases ownership', async mode => {
const { db, scaling, saved } = await setup(mode);
db().prepare('DELETE FROM server_keys').run();
scaling.deleteScaling(saved.id, saved.revision);
const { scalingOwner } = await import('../../src/lib/server/autoscaling/ownership.js');
expect(scalingOwner('t6', 'town')).toBeNull();
expect(await scaling.runAutoscaling()).toBe(false);
expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 });
});
it('rejects stale revisions and preserves the latest settings', async () => {
const { scaling, saved } = await setup();
const off = { ...settings, mode: 'off' };
const edited = scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
expect(() => scaling.deleteScaling(saved.id, saved.revision)).toThrow('changed');
expect(scaling.getScalingPolicy(saved.id)).toEqual(edited);
expect(() => scaling.deleteScaling('missing', 1)).toThrow('not found');
});
it('blocks deletion with pending intent, then keeps Provisioning history after pause and settlement', async () => {
const { db, scaling, saved } = await setup('create-only');
await scaling.runAutoscaling();
const { operation_id: id } = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
expect(() => scaling.deleteScaling(saved.id, saved.revision)).toThrow('outstanding Provisioning');
expect(scaling.getScalingPolicy(saved.id)?.mode).toBe('create-only');
const off = { ...settings, mode: 'off' };
const paused = scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
scaling.deleteScaling(saved.id, paused.revision);
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
expect(getDeployment(id)).toMatchObject({ deployment: { state: 'cancelled' }, sources: [expect.objectContaining({ kind: 'autoscaling', source_id: saved.id, label: 'Deleted autoscaling policy', href: null })] });
});
it('validates delete requests and returns conflict, not-found, and success statuses', async () => {
const { scaling, saved } = await setup();
const { POST } = await import('../../src/routes/api/autoscaling/+server.js');
const request = (body: object) => POST({ request: new Request('http://localhost/api/autoscaling', { method: 'POST', body: JSON.stringify({ action: 'delete', ...body }) }) });
for (const body of [{}, { id: saved.id }, { id: saved.id, revision: '1' }, { id: saved.id, revision: 0 }]) expect((await request(body)).status).toBe(400);
expect((await request({ id: saved.id, revision: saved.revision + 1 })).status).toBe(409);
expect((await request({ id: saved.id, revision: saved.revision })).status).toBe(200);
expect(scaling.getScalingPolicy(saved.id)).toBeUndefined();
expect((await request({ id: saved.id, revision: saved.revision })).status).toBe(404);
});
+2
View File
@@ -89,6 +89,8 @@ it('revokes a delivered removal on policy pause without claiming it was undone',
expect(s.db().prepare('SELECT owns_map FROM autoscaling_policies').get()).toEqual({ owns_map: 1 }); expect(s.db().prepare('SELECT owns_map FROM autoscaling_policies').get()).toEqual({ owns_map: 1 });
expect(s.db().prepare('SELECT state FROM server_deployments WHERE id=?').get(s.operationId)).toEqual({ state: 'running' }); expect(s.db().prepare('SELECT state FROM server_deployments WHERE id=?').get(s.operationId)).toEqual({ state: 'running' });
expect(s.scaling.scalingReport().policies[0]).toMatchObject({ ownsMap: true, unsettled: [{ id: s.operationId, state: 'running' }] }); expect(s.scaling.scalingReport().policies[0]).toMatchObject({ ownsMap: true, unsettled: [{ id: s.operationId, state: 'running' }] });
expect(() => s.scaling.deleteScaling(s.policy.id, s.scaling.getScalingPolicy(s.policy.id)!.revision)).toThrow('outstanding Provisioning');
expect(s.db().prepare('SELECT COUNT(*) AS n FROM autoscaling_authorizations WHERE policy_id=?').get(s.policy.id)).toEqual({ n: 1 });
}); });
it('withdraws undelivered commands and reconciles invalidation without a retry or failure pause', async () => { it('withdraws undelivered commands and reconciles invalidation without a retry or failure pause', async () => {