Require revocable authorization for automatic lobby removal

This commit is contained in:
2026-10-06 23:06:04 -04:00
parent e012278bd9
commit e271f31325
18 changed files with 357 additions and 27 deletions
+2 -2
View File
@@ -148,13 +148,13 @@ export function canCancelRemoval(id: string): boolean {
}
/** Competes atomically with the worker's claim, before any external removal. */
export function cancelRemoval(id: string): boolean {
export function cancelRemoval(id: string, pausePolicy = true): boolean {
return db().transaction(() => {
const finishedAt = new Date().toISOString();
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(finishedAt, id, REMOVING_SERVER);
if (!result.changes) return false;
pauseScalingForOperation(id);
if (pausePolicy) pauseScalingForOperation(id);
recordProgress(id, { blocker: null, checks: [], event: { kind: 'outcome',
message: 'Queued removal cancelled. The server and its files were kept.',
details: { state: 'cancelled', finishedAt } } });
+8 -3
View File
@@ -1,4 +1,5 @@
import { scalingOwnershipBlocker } from '../autoscaling/ownership.js';
import { scalingOwnershipBlocker, assertScalingOwnership } from '../autoscaling/ownership.js';
import { mapIdentity } from '../autoscaling/snapshot.js';
import { db } from '../db/index.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
import type { GameMode, ZombieGame } from '../fleet/map-aliases.js';
@@ -68,9 +69,13 @@ function hasActiveCreation(name: string): boolean {
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
}
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
export function destroyServer(name: string, triggeredBy = 'ui', autoscalingDecisionId?: string): DestroyServerResult & { deploymentId?: string } {
const owned = scalingOwnershipBlocker(name);
if (owned) return { ok: false, kind: 'conflict', message: owned, warnings: [] };
if (autoscalingDecisionId) {
const identity = mapIdentity(getServer(name) ?? { name });
if (!identity) return { ok: false, kind: 'conflict', message: 'Map identity unavailable.', warnings: [] };
assertScalingOwnership(identity.game, identity.mapSlug, autoscalingDecisionId);
} else if (owned) return { ok: false, kind: 'conflict', message: owned, warnings: [] };
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
const blocker = activeBetaRemovalBlocker(name);
+15 -2
View File
@@ -1,4 +1,6 @@
import { assertScalingOwnership } from '../autoscaling/ownership.js';
import { removalAuthorization } from '../autoscaling/authorization.js';
import { scalingIntent } from '../autoscaling/admission.js';
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
import { concurrencyBlocker, preparingJobs } from './concurrency.js';
import { isIP } from 'node:net';
@@ -18,7 +20,7 @@ import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER } from './history.js';
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER, cancelRemoval } from './history.js';
import { recordProgress, type CheckInput } from './progress.js';
import { trackActivity } from '../pipeline/activity.js';
import { fleetSettings } from '../settings.js';
@@ -659,7 +661,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
JOIN watchdog_incidents i ON i.id=s.source_id JOIN server_deployments d ON d.id=s.operation_id
WHERE s.operation_id=? AND s.kind='watchdog' AND i.kind='health' AND d.triggered_by LIKE 'watchdog:%'`).get(job.id);
if (recoveredWatchdog && occupancy.players !== null) { resolveRecoveredHealthOperation(job.id, occupancy.players, occupancy.source); return false; }
if (occupancy.players !== 0) { hold(job, occupancy.players !== null ? `Waiting for ${occupancy.players} player(s) to leave.` : 'Waiting for a fresh local player count.'); return false; }
if (occupancy.players !== 0) {
if (job.action === 'destroy' && scalingIntent(job.id)) cancelRemoval(job.id, false);
else hold(job, occupancy.players !== null ? `Waiting for ${occupancy.players} player(s) to leave.` : 'Waiting for a fresh local player count.');
return false;
}
}
return true;
};
@@ -729,6 +735,13 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
? 'Host eligibility checked; player safety checks bypassed by admin override' : 'Host eligibility and safety checks passed' } });
const payload: Record<string, unknown> = files.length ? { stageId: job.id } : {};
if (overrideSafety) payload.safetyOverride = true;
if (job.action === 'destroy') {
const authorization = removalAuthorization(job.id, instance!.host_id);
if (authorization) {
if (overrideSafety) throw new Error('Autoscaling never bypasses player safety.');
payload.autoscaling = authorization;
}
}
if (prepared) {
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
@@ -19,6 +19,7 @@ export function safetyOverrideAvailability(id: string): { applicable: boolean; u
AND d.state IN ('queued','waiting') AND d.submitted_at IS NULL AND n.operation_id IS NULL
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.deployment_id=d.id)
AND NOT EXISTS (SELECT 1 FROM native_creation_cancellations c WHERE c.cleanup_id=d.id)
AND NOT EXISTS (SELECT 1 FROM autoscaling_decisions a WHERE a.operation_id=d.id)
AND NOT EXISTS (SELECT 1 FROM provisioning_safety_overrides o WHERE o.deployment_id=d.id)`)
.get(id) as { host_id: string } | undefined;
if (!target) return { applicable: false, unavailableReason: null };