diff --git a/docs/autoscaling.md b/docs/autoscaling.md index d1e140e..3bbb9b5 100644 --- a/docs/autoscaling.md +++ b/docs/autoscaling.md @@ -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. +## 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 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. -`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. diff --git a/src/lib/server/autoscaling/index.ts b/src/lib/server/autoscaling/index.ts index 4840b8e..8f11357 100644 --- a/src/lib/server/autoscaling/index.ts +++ b/src/lib/server/autoscaling/index.ts @@ -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 { 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'); diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 7a6cda3..1d275b1 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -60,7 +60,10 @@ function warningMessages(raw: string): string[] { function sourceDetails(source: Omit): ProvisioningSource { 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') { 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)) diff --git a/src/routes/api/autoscaling/+server.ts b/src/routes/api/autoscaling/+server.ts index fbce1ca..68e1e53 100644 --- a/src/routes/api/autoscaling/+server.ts +++ b/src/routes/api/autoscaling/+server.ts @@ -1,5 +1,5 @@ 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 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.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 === '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 }); } throw new ScalingError('Choose a supported policy action.'); } catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); } diff --git a/src/routes/autoscaling/+page.svelte b/src/routes/autoscaling/+page.svelte index 8741787..a388bf9 100644 --- a/src/routes/autoscaling/+page.svelte +++ b/src/routes/autoscaling/+page.svelte @@ -1,6 +1,7 @@