Support game-wide autoscaling rules with per-map targets

This commit is contained in:
2026-10-06 23:34:16 -04:00
parent a61eccaed6
commit bc744e2840
11 changed files with 152 additions and 32 deletions
+6 -6
View File
@@ -1,12 +1,12 @@
# Lobby autoscaling # Lobby autoscaling
Open **Fleet → Autoscaling** to keep empty lobbies available for new groups. Policies apply to a specific game and map variant. A partly occupied Zombies lobby is not an empty spare, even when it still has player slots. Open **Fleet → Autoscaling** to keep empty lobbies available for new groups. Policies apply to one map variant or every map in a game. A game-wide policy applies the same minimum, maximum, and spare targets independently to each map, including all variants. A partly occupied Zombies lobby is not an empty spare, even when it still has player slots.
Autoscaling is opt-in. Installing the feature creates no policies and changes no lobby counts. Use a two-week dry run before enabling live actions. Autoscaling is opt-in. Installing the feature creates no policies and changes no lobby counts. Use a two-week dry run before enabling live actions.
## Configure a policy ## Configure a policy
Choose **Add policy**, select the game and map, and enter an explicit maximum. The initial editor selects Dry run, with a minimum of one running lobby, one empty spare, a 20-minute empty interval, and a five-minute action cooldown. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum. Choose **Add policy**, select the game and scope, and enter an explicit maximum. The initial editor selects Dry run, with a minimum of one running lobby, one empty spare, a 20-minute empty interval, and a five-minute action cooldown. Counts must be between 1 and 20; the minimum and spare target cannot exceed the maximum.
Choose **Preview changes** to inspect the existing lobbies, saved key coverage, target, proposed action, and blockers. **Save reviewed policy** checks the policy revision and current fleet again. If the preview changed, review a new preview before saving. Choose **Preview changes** to inspect the existing lobbies, saved key coverage, target, proposed action, and blockers. **Save reviewed policy** checks the policy revision and current fleet again. If the preview changed, review a new preview before saving.
@@ -19,11 +19,11 @@ Choose **Preview changes** to inspect the existing lobbies, saved key coverage,
Live activation requires saved Plutonium keys for enough exact lobby identities to support the configured maximum. Keys may be nonconsecutive: if only lobby 3 and lobby 8 are keyed, those identities can be used. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host. Live activation requires saved Plutonium keys for enough exact lobby identities to support the configured maximum. Keys may be nonconsecutive: if only lobby 3 and lobby 8 are keyed, those identities can be used. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per map. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins. The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies share one cooldown and one outstanding operation across their maps, prioritizing additions before removals. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
## Ownership and blockers ## Ownership and blockers
A live policy owns the map's lobby count. Competing collection launches and manual Create, Start, Stop, Rebuild, or Destroy requests are rejected with a policy link. Exact collection matching also checks managed maps absent from the collection. To hand control back, set the policy to Off and wait for unsettled operations to finish. A live policy owns the lobby counts for its selected maps. Overlapping live game-wide and map-specific policies are rejected; dry-run policies may overlap. Game-wide rules include every catalog map and require key coverage for the maximum on each map. Competing collection launches and manual Create, Start, Stop, Rebuild, or Destroy requests are rejected with a policy link. Exact collection matching also checks managed maps absent from the collection. To hand control back, set the policy to Off and wait for unsettled operations to finish.
Unknown or stale occupancy, stopped or unhealthy members, active betas, campaigns, maintenance, host drains, and unresolved recovery hold the policy's decisions. Autoscaling does not repair these conditions itself. The page and linked Provisioning operation explain the current blocker, including capacity and missing-key waits. Unknown or stale occupancy, stopped or unhealthy members, active betas, campaigns, maintenance, host drains, and unresolved recovery hold the policy's decisions. Autoscaling does not repair these conditions itself. The page and linked Provisioning operation explain the current blocker, including capacity and missing-key waits.
@@ -31,7 +31,7 @@ Current positive occupancy from either telemetry or RCON vetoes an empty claim.
## Pause and cancellation ## Pause and cancellation
Use **Pause all** to stop new decisions globally, or **Edit or pause → Off** for one map. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's selected mode. Use **Pause all** to stop new decisions globally, or **Edit or pause → Off** for one policy. A global pause retains each live policy's ownership; setting it Off releases ownership once its outstanding operation settles. Resuming the global switch preserves each policy's selected mode.
Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement. Unsubmitted creates and queued removals are withdrawn where possible. Cancelling an autoscaling operation from Provisioning also turns its policy Off so it cannot immediately request a replacement.
@@ -59,6 +59,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. 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`, 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.
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.
+2
View File
@@ -146,3 +146,5 @@ Validation includes planner and snapshot tests, duplicate-admission and stale-pr
The full controller suite reported 1,934 passing tests and two existing failures in `server-detail-api.test.ts` (plugin classification) and `watchdog.test.ts` (an occupied-telemetry RCON expectation). Both failures reproduce independently at the pre-implementation commit `a22461a`. They are not autoscaling regressions. Targeted controller tests, all 228 Python agent tests, type checking, and the production build passed. The full controller suite reported 1,934 passing tests and two existing failures in `server-detail-api.test.ts` (plugin classification) and `watchdog.test.ts` (an occupied-telemetry RCON expectation). Both failures reproduce independently at the pre-implementation commit `a22461a`. They are not autoscaling regressions. Targeted controller tests, all 228 Python agent tests, type checking, and the production build passed.
An isolated Firefox review exercised policy creation, preview, save, keyboard focus, and 1440, 390, and 320 pixel layouts. A narrow-layout heading issue found during review was corrected. The preview used a temporary database and no production host credentials. Production CPU savings, external joinability, spare targets, and acceptable deficit durations still require the two-week observation period and operator-run pilot. An isolated Firefox review exercised policy creation, preview, save, keyboard focus, and 1440, 390, and 320 pixel layouts. A narrow-layout heading issue found during review was corrected. The preview used a temporary database and no production host credentials. Production CPU savings, external joinability, spare targets, and acceptable deficit durations still require the two-week observation period and operator-run pilot.
Game-wide policy follow-up: selecting every map applies the targets independently to each catalog map and variant. Game-wide policies serialize actions across maps and cannot overlap live map-specific ownership. Existing map policies remain compatible.
+7 -4
View File
@@ -3,14 +3,17 @@ import { zombieMapBySlug, type ZombieGame } from './map-aliases.js';
export const autoscalingModes = ['off', 'dry-run', 'create-only', 'create-destroy'] as const; export const autoscalingModes = ['off', 'dry-run', 'create-only', 'create-destroy'] as const;
export type AutoscalingMode = typeof autoscalingModes[number]; export type AutoscalingMode = typeof autoscalingModes[number];
export interface ScalingSettings { export interface ScalingSettings {
game: ZombieGame; mapSlug: string; mode: AutoscalingMode; game: ZombieGame;
/** '*' applies the targets independently to every map. */
mapSlug: string;
mode: AutoscalingMode;
minimum: number; maximum: number; spares: number; emptyMinutes: number; cooldownMinutes: number; minimum: number; maximum: number; spares: number; emptyMinutes: number; cooldownMinutes: number;
} }
export interface ScalingPolicy extends ScalingSettings { export interface ScalingPolicy extends ScalingSettings {
id: string; revision: number; nextActionAt: number; pausedReason: string | null; updatedAt: string; id: string; revision: number; nextActionAt: number; pausedReason: string | null; updatedAt: string;
} }
export interface ScalingMember { export interface ScalingMember {
name: string; identity: string; state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected'; name: string; mapSlug?: string; identity: string; state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
players: number | null; observedAt: number | null; source: string | null; players: number | null; observedAt: number | null; source: string | null;
emptySince: number | null; blocker: string | null; operationId: string | null; emptySince: number | null; blocker: string | null; operationId: string | null;
emptyStartedAt?: number | null; emptyStartedAt?: number | null;
@@ -20,7 +23,7 @@ export interface ScalingSnapshot {
} }
export interface ScalingDecision { export interface ScalingDecision {
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null; action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
desired: number; occupied: number; empty: number; pending: number; unknown: number; desired: number; deficit: number; occupied: number; empty: number; pending: number; unknown: number;
} }
export interface ScalingPreview { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; token: string; blockers: string[] } export interface ScalingPreview { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; token: string; blockers: string[] }
export interface ScalingHistory { id: string; at: number; lastSeenAt: number; decision: ScalingDecision; operationId: string | null; operationState: string | null } export interface ScalingHistory { id: string; at: number; lastSeenAt: number; decision: ScalingDecision; operationId: string | null; operationState: string | null }
@@ -38,7 +41,7 @@ export function scalingSettings(input: unknown): ScalingSettings {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide an autoscaling policy.'); if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Provide an autoscaling policy.');
const value = input as Record<string, unknown>; const value = input as Record<string, unknown>;
const { game, mapSlug } = value; const { game, mapSlug } = value;
if ((game !== 't5' && game !== 't6') || typeof mapSlug !== 'string' || zombieMapBySlug(game, mapSlug)?.slug !== mapSlug) throw new Error('Choose a supported game and map variant.'); if ((game !== 't5' && game !== 't6') || typeof mapSlug !== 'string' || (mapSlug !== '*' && zombieMapBySlug(game, mapSlug)?.slug !== mapSlug)) throw new Error('Choose a supported game and map variant, or all maps.');
const mode = value.mode ?? 'off'; const mode = value.mode ?? 'off';
if (!autoscalingModes.includes(mode as AutoscalingMode)) throw new Error('Choose a valid autoscaling mode.'); if (!autoscalingModes.includes(mode as AutoscalingMode)) throw new Error('Choose a valid autoscaling mode.');
const integer = (key: string, fallback: number | undefined, max: number) => { const integer = (key: string, fallback: number | undefined, max: number) => {
+10 -4
View File
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingPreview, type ScalingReport, type ScalingView } from '../../autoscaling.js'; import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingPreview, type ScalingReport, type ScalingView } from '../../autoscaling.js';
import { emptyObservation, planScaling } from './planner.js'; import { emptyObservation, planScaling } from './planner.js';
import { zombieMaps, parseServerName } from '../../map-aliases.js';
import { scalingSnapshot } from './snapshot.js'; import { scalingSnapshot } from './snapshot.js';
import { nativeLaunchSelection, queueNativeCreationIntent } from '../provisioning/native.js'; import { nativeLaunchSelection, queueNativeCreationIntent } from '../provisioning/native.js';
import { cancelCreation } from '../provisioning/cancel-creation.js'; import { cancelCreation } from '../provisioning/cancel-creation.js';
@@ -10,6 +11,7 @@ import { destroyServer } from '../provisioning/instances.js';
import { cancelRemoval } from '../provisioning/history.js'; import { cancelRemoval } from '../provisioning/history.js';
import { autoscalingRemovalCapability, revokePolicyRemovals } from './authorization.js'; import { autoscalingRemovalCapability, revokePolicyRemovals } from './authorization.js';
import { getHost } from '../hosts/index.js'; import { getHost } from '../hosts/index.js';
import { scalingOwner } from './ownership.js';
import { getServer } from '../fleet/servers.js'; import { getServer } from '../fleet/servers.js';
export class ScalingError extends Error { constructor(message: string, public status = 400) { super(message); } } export class ScalingError extends Error { constructor(message: string, public status = 400) { super(message); } }
@@ -29,7 +31,11 @@ export function previewScaling(input: unknown, id?: string, revision?: number):
const snapshot = scalingSnapshot(policy); const snapshot = scalingSnapshot(policy);
const blockers: string[] = []; const blockers: string[] = [];
if (liveScaling(policy.mode)) { if (liveScaling(policy.mode)) {
if (snapshot.keyedNames.length < policy.maximum) blockers.push(`Save keys for at least ${policy.maximum} lobby identities before activating this policy.`); const owner = scalingOwner(policy.game, policy.mapSlug);
if (owner && owner !== policy.id) blockers.push(`An overlapping policy owns this lineup. Pause it and settle pending work first: /autoscaling#${owner}`);
for (const map of zombieMaps(policy.game).filter(m => policy.mapSlug === '*' || m.slug === policy.mapSlug)) {
if (snapshot.keyedNames.filter(name => parseServerName(name)?.mapSlug === map.slug).length < policy.maximum) blockers.push(`Save keys for at least ${policy.maximum} lobby identities for ${map.friendly} before activating this policy.`);
}
blockers.push(...snapshot.blockers); blockers.push(...snapshot.blockers);
for (const member of snapshot.members) if (!['occupied', 'empty'].includes(member.state)) blockers.push(`${member.name}: ${member.blocker ?? member.state}. Settle this work before activation.`); for (const member of snapshot.members) if (!['occupied', 'empty'].includes(member.state)) blockers.push(`${member.name}: ${member.blocker ?? member.state}. Settle this work before activation.`);
if (policy.mode === 'create-destroy') for (const member of snapshot.members) { if (policy.mode === 'create-destroy') for (const member of snapshot.members) {
@@ -52,7 +58,7 @@ export function saveScaling(input: unknown, id?: string, revision?: number, toke
if (preview.blockers.length) throw new ScalingError(preview.blockers[0]); if (preview.blockers.length) throw new ScalingError(preview.blockers[0]);
const policy = preview.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString(); const policy = preview.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString();
if (id) revokePolicyRemovals(id); if (id) revokePolicyRemovals(id);
if (!id && db().prepare('SELECT 1 FROM autoscaling_policies WHERE game=? AND map_slug=?').get(policy.game, policy.mapSlug)) throw new ScalingError('A policy already exists for this map.', 409); if (!id && db().prepare('SELECT 1 FROM autoscaling_policies WHERE game=? AND map_slug=?').get(policy.game, policy.mapSlug)) throw new ScalingError('A policy already exists for this scope.', 409);
db().prepare(`INSERT INTO autoscaling_policies(id,game,map_slug,settings,mode,updated_at) VALUES(?,?,?,?,?,?) db().prepare(`INSERT INTO autoscaling_policies(id,game,map_slug,settings,mode,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET settings=excluded.settings,mode=excluded.mode,revision=autoscaling_policies.revision+1,paused_reason=NULL,updated_at=excluded.updated_at`).run(nextId, policy.game, policy.mapSlug, JSON.stringify(settings), policy.mode, at); ON CONFLICT(id) DO UPDATE SET settings=excluded.settings,mode=excluded.mode,revision=autoscaling_policies.revision+1,paused_reason=NULL,updated_at=excluded.updated_at`).run(nextId, policy.game, policy.mapSlug, JSON.stringify(settings), policy.mode, at);
// An edit starts a new observation interval; earlier inactivity is not authorization. // An edit starts a new observation interval; earlier inactivity is not authorization.
@@ -139,7 +145,7 @@ export async function runAutoscaling(): Promise<boolean> {
try { try {
db().transaction(() => { db().transaction(() => {
const result = decision.action === 'create' const result = decision.action === 'create'
? queueNativeCreationIntent({ server: decision.server!, game: policy.game, mapSlug: policy.mapSlug, ? queueNativeCreationIntent({ server: decision.server!, game: policy.game, mapSlug: parseServerName(decision.server!)!.mapSlug,
selection: nativeLaunchSelection({ automatic: true }, policy.game), createdBy: `autoscaling:${policy.id}`, autoscalingDecisionId: decisionId }) selection: nativeLaunchSelection({ automatic: true }, policy.game), createdBy: `autoscaling:${policy.id}`, autoscalingDecisionId: decisionId })
: destroyServer(decision.server!, `autoscaling:${policy.id}`, decisionId); : destroyServer(decision.server!, `autoscaling:${policy.id}`, decisionId);
if (!result.ok) throw new Error(result.message); if (!result.ok) throw new Error(result.message);
@@ -155,7 +161,7 @@ export async function runAutoscaling(): Promise<boolean> {
} }
} }
db().prepare(`INSERT OR IGNORE INTO autoscaling_samples(policy_id,sampled_at,mode,occupied,empty,unknown,deficit) VALUES(?,?,?,?,?,?,?)`) db().prepare(`INSERT OR IGNORE INTO autoscaling_samples(policy_id,sampled_at,mode,occupied,empty,unknown,deficit) VALUES(?,?,?,?,?,?,?)`)
.run(policy.id, Math.floor(now / 15_000) * 15_000, policy.mode, decision.occupied, decision.empty, decision.unknown, Math.max(0, policy.spares - decision.empty)); .run(policy.id, Math.floor(now / 15_000) * 15_000, policy.mode, decision.occupied, decision.empty, decision.unknown, decision.deficit);
})(); })();
} }
return submitted; return submitted;
+1 -1
View File
@@ -3,7 +3,7 @@ import { getServer } from '../fleet/servers.js';
import { mapIdentity } from './snapshot.js'; import { mapIdentity } from './snapshot.js';
export function scalingOwner(game: string, mapSlug: string): string | null { export function scalingOwner(game: string, mapSlug: string): string | null {
return (db().prepare('SELECT id FROM autoscaling_policies WHERE game=? AND map_slug=? AND owns_map=1').get(game, mapSlug) as { id: string } | undefined)?.id ?? null; return (db().prepare("SELECT id FROM autoscaling_policies WHERE game=? AND (map_slug=? OR map_slug='*' OR ?='*') AND owns_map=1").get(game, mapSlug, mapSlug) as { id: string } | undefined)?.id ?? null;
} }
export function scalingOwnershipBlocker(server: string): string | null { export function scalingOwnershipBlocker(server: string): string | null {
const identity = mapIdentity(getServer(server) ?? { name: server }); const identity = mapIdentity(getServer(server) ?? { name: server });
+21 -1
View File
@@ -1,4 +1,5 @@
import type { ScalingDecision, ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js'; import type { ScalingDecision, ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
import { parseServerName, zombieMaps } from '../../map-aliases.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
export interface EmptyObservation { identity: string; observedAt: number | null; emptySince: number | null; evaluatedAt: number } export interface EmptyObservation { identity: string; observedAt: number | null; emptySince: number | null; evaluatedAt: number }
@@ -14,13 +15,32 @@ export function emptyObservation(member: ScalingMember, previous: EmptyObservati
} }
export function planScaling(policy: ScalingPolicy, snapshot: ScalingSnapshot, now: number): ScalingDecision { export function planScaling(policy: ScalingPolicy, snapshot: ScalingSnapshot, now: number): ScalingDecision {
if (policy.mapSlug === '*') {
const maps = zombieMaps(policy.game);
const decisions = maps.map(map => {
const matches = (name: string) => (snapshot.members.find(m => m.name === name)?.mapSlug ?? parseServerName(name)?.mapSlug) === map.slug;
return planScaling({ ...policy, mapSlug: map.slug }, {
...snapshot, members: snapshot.members.filter(m => matches(m.name)),
reservedNames: snapshot.reservedNames.filter(matches), keyedNames: snapshot.keyedNames.filter(matches)
}, now);
});
// Serialize game-wide work and prefer replenishing any map before trimming another.
const pending = snapshot.members.find(m => m.state === 'creating' || m.state === 'removing');
const selected = decisions.find(d => d.action === 'create') ?? decisions.find(d => d.action === 'destroy')
?? decisions.find(d => d.deficit > 0 || d.unknown > 0) ?? decisions[0];
const map = maps[decisions.indexOf(selected)];
return { ...selected, action: pending ? 'wait' : selected.action, server: pending ? null : selected.server,
reason: pending ? pending.blocker ?? `Waiting for ${pending.name}: ${pending.state}.` : `${map.friendly}: ${selected.reason}`,
desired: decisions.reduce((n, d) => n + d.desired, 0), deficit: decisions.reduce((n, d) => n + d.deficit, 0), occupied: decisions.reduce((n, d) => n + d.occupied, 0),
empty: decisions.reduce((n, d) => n + d.empty, 0), pending: decisions.reduce((n, d) => n + d.pending, 0), unknown: decisions.reduce((n, d) => n + d.unknown, 0) };
}
const { members } = snapshot; const { members } = snapshot;
const occupied = members.filter(m => m.state === 'occupied').length, empty = members.filter(m => m.state === 'empty').length; const occupied = members.filter(m => m.state === 'occupied').length, empty = members.filter(m => m.state === 'empty').length;
const pending = members.filter(m => m.state === 'creating' || m.state === 'removing').length; const pending = members.filter(m => m.state === 'creating' || m.state === 'removing').length;
const unknown = members.filter(m => m.state === 'unknown' || m.state === 'stopped').length; const unknown = members.filter(m => m.state === 'unknown' || m.state === 'stopped').length;
const desired = Math.min(policy.maximum, Math.max(policy.minimum, occupied + policy.spares)); const desired = Math.min(policy.maximum, Math.max(policy.minimum, occupied + policy.spares));
const result = (action: ScalingDecision['action'], reason: string, server: string | null = null): ScalingDecision => const result = (action: ScalingDecision['action'], reason: string, server: string | null = null): ScalingDecision =>
({ action, reason, server, desired, occupied, empty, pending, unknown }); ({ action, reason, server, desired, deficit: Math.max(0, policy.spares - empty), occupied, empty, pending, unknown });
if (policy.mode === 'off') return result('wait', policy.pausedReason ?? 'Policy is off.'); if (policy.mode === 'off') return result('wait', policy.pausedReason ?? 'Policy is off.');
if (policy.pausedReason) return result('wait', policy.pausedReason); if (policy.pausedReason) return result('wait', policy.pausedReason);
if (snapshot.blockers.length) return result('wait', snapshot.blockers[0]); if (snapshot.blockers.length) return result('wait', snapshot.blockers[0]);
+3 -3
View File
@@ -28,7 +28,7 @@ export function protectedWork(server: string, exceptOperation = ''): string | nu
export function scalingSnapshot(policy: ScalingPolicy, now = Date.now(), exceptOperation = ''): ScalingSnapshot { export function scalingSnapshot(policy: ScalingPolicy, now = Date.now(), exceptOperation = ''): ScalingSnapshot {
const handle = db(), observe = observationReader(); const handle = db(), observe = observationReader();
const rows = listServers(); const rows = listServers();
const matches = (value: Parameters<typeof mapIdentity>[0]) => { const identity = mapIdentity(value); return identity?.game === policy.game && identity.mapSlug === policy.mapSlug; }; const matches = (value: Parameters<typeof mapIdentity>[0]) => { const identity = mapIdentity(value); return identity?.game === policy.game && (policy.mapSlug === '*' || identity.mapSlug === policy.mapSlug); };
const blockers: string[] = []; const blockers: string[] = [];
for (const row of rows) if (!mapIdentity(row) && (row.game ?? parseServerName(row.name)?.game) === policy.game) blockers.push(`Resolve ambiguous map identity for ${row.name}.`); for (const row of rows) if (!mapIdentity(row) && (row.game ?? parseServerName(row.name)?.game) === policy.game) blockers.push(`Resolve ambiguous map identity for ${row.name}.`);
const operations = handle.prepare(`SELECT id,server,action,state,stage FROM server_deployments WHERE server_deleted_at IS NULL const operations = handle.prepare(`SELECT id,server,action,state,stage FROM server_deployments WHERE server_deleted_at IS NULL
@@ -54,12 +54,12 @@ export function scalingSnapshot(policy: ScalingPolicy, now = Date.now(), exceptO
else if (row.desired_state === 'stopped' || ['stopped','exited','created','dead'].includes(row.container_state ?? '')) state = 'stopped'; else if (row.desired_state === 'stopped' || ['stopped','exited','created','dead'].includes(row.container_state ?? '')) state = 'stopped';
else if (observation.health === 'online' && players !== null && observedAt !== null && observedAt <= now && freshStatusTimestamp(new Date(observedAt).toISOString(), now)) state = players > 0 ? 'occupied' : 'empty'; else if (observation.health === 'online' && players !== null && observedAt !== null && observedAt <= now && freshStatusTimestamp(new Date(observedAt).toISOString(), now)) state = players > 0 ? 'occupied' : 'empty';
const transitions = [row.inactive_since, observation.inactive_since].map(at => Date.parse(at ?? '')).filter(at => Number.isFinite(at) && at <= now); const transitions = [row.inactive_since, observation.inactive_since].map(at => Date.parse(at ?? '')).filter(at => Number.isFinite(at) && at <= now);
return { name: row.name, identity, state, players, observedAt, source: source ?? null, emptySince: null, return { name: row.name, mapSlug: mapIdentity(row)!.mapSlug, identity, state, players, observedAt, source: source ?? null, emptySince: null,
emptyStartedAt: transitions.length ? Math.max(...transitions) : null, blocker, operationId: operation?.id ?? null }; emptyStartedAt: transitions.length ? Math.max(...transitions) : null, blocker, operationId: operation?.id ?? null };
}); });
for (const row of reservations.filter(matches)) if (!members.some(m => m.name === row.name)) { for (const row of reservations.filter(matches)) if (!members.some(m => m.name === row.name)) {
const operation = operations.find(o => o.server === row.name); const operation = operations.find(o => o.server === row.name);
members.push({ name: row.name, identity: 'reserved', state: 'creating', players: null, observedAt: null, source: null, emptySince: null, blocker: operation?.stage ?? 'Creation is pending.', operationId: operation?.id ?? null }); members.push({ name: row.name, mapSlug: row.map_slug, identity: 'reserved', state: 'creating', players: null, observedAt: null, source: null, emptySince: null, blocker: operation?.stage ?? 'Creation is pending.', operationId: operation?.id ?? null });
} }
const retained = handle.prepare('SELECT server,observation FROM autoscaling_members WHERE policy_id=?').all(policy.id) as { server: string; observation: string }[]; const retained = handle.prepare('SELECT server,observation FROM autoscaling_members WHERE policy_id=?').all(policy.id) as { server: string; observation: string }[];
for (const member of members) { for (const member of members) {
+9 -8
View File
@@ -73,8 +73,8 @@
<form onsubmit={event => { event.preventDefault(); void submit('preview'); }} oninput={() => preview = null} onchange={() => preview = null}> <form onsubmit={event => { event.preventDefault(); void submit('preview'); }} oninput={() => preview = null} onchange={() => preview = null}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
<div class="fields"> <div class="fields">
<label>Game<select bind:value={settings.game} disabled={!!editing} onchange={() => settings.mapSlug = zombieMaps(settings.game)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label> <label>Game<select bind:value={settings.game} disabled={!!editing} onchange={() => { if (settings.mapSlug !== '*') settings.mapSlug = zombieMaps(settings.game)[0].slug; }}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map variant<select bind:value={settings.mapSlug} disabled={!!editing}>{#each zombieMaps(settings.game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label> <label>Scope<select bind:value={settings.mapSlug} disabled={!!editing}><option value="*">Every map in this game</option>{#each zombieMaps(settings.game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
<label>Mode<select bind:value={settings.mode}><option value="off">Off</option><option value="dry-run">Dry run</option>{#if report.liveAvailable}<option value="create-only">Create only</option>{/if}{#if report.removalAvailable}<option value="create-destroy">Create and destroy</option>{/if}</select></label> <label>Mode<select bind:value={settings.mode}><option value="off">Off</option><option value="dry-run">Dry run</option>{#if report.liveAvailable}<option value="create-only">Create only</option>{/if}{#if report.removalAvailable}<option value="create-destroy">Create and destroy</option>{/if}</select></label>
<label>Minimum running lobbies<input type="number" min="1" max="20" required bind:value={settings.minimum} /></label> <label>Minimum running lobbies<input type="number" min="1" max="20" required bind:value={settings.minimum} /></label>
<label>Maximum lobbies<input type="number" min="1" max="20" required bind:value={settings.maximum} /></label> <label>Maximum lobbies<input type="number" min="1" max="20" required bind:value={settings.maximum} /></label>
@@ -82,15 +82,16 @@
<label>Empty duration in minutes<input type="number" min="1" max="1440" required bind:value={settings.emptyMinutes} /></label> <label>Empty duration in minutes<input type="number" min="1" max="1440" required bind:value={settings.emptyMinutes} /></label>
<label>Action cooldown in minutes<input type="number" min="1" max="1440" required bind:value={settings.cooldownMinutes} /></label> <label>Action cooldown in minutes<input type="number" min="1" max="1440" required bind:value={settings.cooldownMinutes} /></label>
</div> </div>
{#if settings.mapSlug === '*'}<p>Targets apply separately to every map, including all variants. Each map needs its own keys. Actions share one cooldown and run one at a time across the game.</p>{/if}
<div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div> <div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
</fieldset> </fieldset>
</form> </form>
{#if preview} {#if preview}
<div class="preview" aria-live="polite"> <div class="preview" aria-live="polite">
<h3>Preview · {modeLabel(preview.policy.mode)}</h3> <h3>Preview · {modeLabel(preview.policy.mode)}</h3>
<p>Target: {preview.decision.desired} running lobbies. {preview.snapshot.keyedNames.length} saved lobby keys. {preview.snapshot.members.length} existing or pending lobbies.</p> <p>Target: {preview.decision.desired} running lobbies{preview.policy.mapSlug === '*' ? ' across all maps' : ''}. {preview.snapshot.keyedNames.length} saved lobby keys. {preview.snapshot.members.length} existing or pending lobbies.</p>
<p>{preview.decision.action === 'wait' ? 'Wait' : `${preview.decision.action === 'create' ? 'Create' : 'Remove'} ${preview.decision.server}`}: {preview.decision.reason}</p> <p>{preview.decision.action === 'wait' ? 'Wait' : `${preview.decision.action === 'create' ? 'Create' : 'Remove'} ${preview.decision.server}`}: {preview.decision.reason}</p>
{#if preview.policy.mode === 'create-only' || preview.policy.mode === 'create-destroy'}<p>Saving gives this policy exclusive control of the map's lobby count. Pause it before launching a competing collection or manual lifecycle action.</p>{/if} {#if preview.policy.mode === 'create-only' || preview.policy.mode === 'create-destroy'}<p>Saving gives this policy exclusive control of the selected maps' lobby counts. Pause it before launching a competing collection or manual lifecycle action.</p>{/if}
{#if preview.snapshot.members.length}<ul>{#each preview.snapshot.members as member}<li>{member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}</li>{/each}</ul>{/if} {#if preview.snapshot.members.length}<ul>{#each preview.snapshot.members as member}<li>{member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}</li>{/each}</ul>{/if}
{#each [...preview.blockers, ...preview.snapshot.blockers] as blocker}<p>{blocker}</p>{/each} {#each [...preview.blockers, ...preview.snapshot.blockers] as blocker}<p>{blocker}</p>{/each}
<Button variant="primary" disabled={busy || preview.blockers.length > 0} onclick={() => submit('save')}>Save reviewed policy</Button> <Button variant="primary" disabled={busy || preview.blockers.length > 0} onclick={() => submit('save')}>Save reviewed policy</Button>
@@ -98,13 +99,13 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to observe a map 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}`}>{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>{/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 map ownership until its outstanding work settles.</p>{/if}</div>{/if} {#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}
<p>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}
<p class="muted">Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}{view.policy.nextActionAt > Date.now() ? ` · Next action after ${$timeFormat.dateTime(new Date(view.policy.nextActionAt).toISOString())}` : ''}</p> <p class="muted">Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}{view.policy.nextActionAt > Date.now() ? ` · Next action after ${$timeFormat.dateTime(new Date(view.policy.nextActionAt).toISOString())}` : ''}</p>
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
import { zombieMaps } from '../../src/lib/map-aliases.js';
import { scalingSettings, type ScalingMember, type ScalingPolicy } from '../../src/lib/autoscaling.js';
import { planScaling } from '../../src/lib/server/autoscaling/planner.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const settings = { game: 't6', mapSlug: '*', maximum: 2, mode: 'create-only' };
const maps = zombieMaps('t6');
async function setup() {
await prepareNativeHost();
const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js');
for (const map of maps) for (const n of [1, 2]) db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_${map.slug}_${n}`, `key-${map.slug}-${n}`);
return { db, scaling };
}
it('applies targets and spare deficits independently to every map', () => {
const now = 2_000_000;
const policy: ScalingPolicy = { ...scalingSettings(settings), id: 'policy', revision: 1, nextActionAt: 0, pausedReason: null, updatedAt: '' };
const members: ScalingMember[] = maps.flatMap(map => [1, 2].map(n => ({ name: `t6_zombie_${map.slug}_${n}`, mapSlug: map.slug,
identity: `${map.slug}:${n}`, state: map.slug === 'town' ? 'occupied' : 'empty', players: map.slug === 'town' ? 1 : 0,
observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null })));
const snapshot = { members, reservedNames: members.map(m => m.name), keyedNames: members.map(m => m.name), blockers: [] };
// Other maps' empty lobbies cannot satisfy Town's spare target or maximum.
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'destroy', server: 't6_zombie_transit_1', desired: maps.length + 1, deficit: 1 });
snapshot.members = members.filter(m => m.name !== 't6_zombie_town_2');
snapshot.reservedNames = snapshot.members.map(m => m.name);
expect(planScaling(policy, snapshot, now)).toMatchObject({ action: 'create', server: 't6_zombie_town_2' });
expect(planScaling({ ...policy, nextActionAt: now + 1 }, snapshot, now).action).toBe('wait');
snapshot.members[0] = { ...snapshot.members[0], state: 'creating' };
expect(planScaling(policy, snapshot, now).action).toBe('wait');
});
it('persists a game rule, queues the selected map recipe, and protects every map', async () => {
const { db, scaling } = await setup();
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run('t5_zombie_theater_1', 'other-game');
const preview = scaling.previewScaling(settings);
expect(preview.snapshot.keyedNames).toHaveLength(maps.length * 2);
const saved = scaling.saveScaling(settings, undefined, undefined, preview.token);
expect(() => scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token)).toThrow();
expect(() => scaling.previewScaling({ ...settings, mapSlug: 'town' }, saved.id, saved.revision)).toThrow('cannot change');
expect(await scaling.runAutoscaling()).toBe(true);
const job = db().prepare('SELECT deployment_id,map_slug,server FROM server_creation_jobs').get() as { deployment_id: string; map_slug: string; server: string };
expect(job).toMatchObject({ map_slug: 'transit', server: 't6_zombie_transit_1' });
const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js');
expect(scalingAdmission(job.deployment_id)).toBeNull();
await scaling.runAutoscaling();
expect(db().prepare('SELECT COUNT(*) AS n FROM server_creation_jobs').get()).toEqual({ n: 1 });
const { scalingOwner, scalingOwnershipBlocker } = await import('../../src/lib/server/autoscaling/ownership.js');
for (const map of maps) expect(scalingOwner('t6', map.slug)).toBe(saved.id);
expect(scalingOwner('t5', 'theater')).toBeNull();
expect(scalingOwnershipBlocker('t6_zombie_farm_1')).toContain(saved.id);
expect(scaling.previewScaling({ ...settings, mapSlug: 'town' }).blockers.join(' ')).toContain('overlapping');
const off = { ...settings, mode: 'off' };
scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
expect(scalingOwner('t6', 'town')).toBeNull();
const mapSettings = { ...settings, mapSlug: 'town' };
const mapPolicy = scaling.saveScaling(mapSettings, undefined, undefined, scaling.previewScaling(mapSettings).token);
const current = scaling.getScalingPolicy(saved.id)!;
expect(scaling.previewScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain(mapPolicy.id);
});
it('requires live key coverage on each map even when the game has enough keys in total', async () => {
const { db, scaling } = await setup();
db().prepare("DELETE FROM server_keys WHERE server_id='t6_zombie_town_2'").run();
const preview = scaling.previewScaling(settings);
expect(preview.blockers.join(' ')).toContain('Town Survival');
expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('Town Survival');
});
+19 -5
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import { zombieMaps } from '../../src/lib/map-aliases.js';
import { prepareNativeHost } from '../helpers/native-host.js'; import { prepareNativeHost } from '../helpers/native-host.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0) })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0) }));
@@ -9,24 +10,30 @@ afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); vi.restoreA
const names = ['t6_zombie_town_1', 't6_zombie_town_2']; const names = ['t6_zombie_town_1', 't6_zombie_town_2'];
const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'create-destroy', emptyMinutes: 1, cooldownMinutes: 1 }; const settings = { game: 't6', mapSlug: 'town', maximum: 2, mode: 'create-destroy', emptyMinutes: 1, cooldownMinutes: 1 };
async function setup() { async function setup(gameWide = false) {
const host = await prepareNativeHost(names); const servers = gameWide ? [...names, ...zombieMaps('t6').filter(m => m.slug !== 'town').map(m => `t6_zombie_${m.slug}_1`)] : names;
const policySettings = { ...settings, mapSlug: gameWide ? '*' : settings.mapSlug };
const host = await prepareNativeHost(servers);
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const scaling = await import('../../src/lib/server/autoscaling/index.js'); const scaling = await import('../../src/lib/server/autoscaling/index.js');
const authorization = await import('../../src/lib/server/autoscaling/authorization.js'); const authorization = await import('../../src/lib/server/autoscaling/authorization.js');
db().prepare("UPDATE ec_hosts SET inventory=json_set(inventory,'$.capabilities',json(?)) WHERE id=?").run(JSON.stringify([authorization.autoscalingRemovalCapability]), host.host.id); db().prepare("UPDATE ec_hosts SET inventory=json_set(inventory,'$.capabilities',json(?)) WHERE id=?").run(JSON.stringify([authorization.autoscalingRemovalCapability]), host.host.id);
const base = Date.now(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(base); const base = Date.now(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(base);
for (const name of names) { for (const name of servers) {
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,last_players) VALUES(?,'t6','zm_transit','town','running',0)").run(name); const map = zombieMaps('t6').find(m => name === `t6_zombie_${m.slug}_1` || name === `t6_zombie_${m.slug}_2`)!;
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,last_players) VALUES(?,'t6',?,?,'running',0)").run(name, map.canonical, map.slug);
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(name, `key-${name}`); db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(name, `key-${name}`);
} }
if (gameWide) for (const map of zombieMaps('t6').filter(m => m.slug !== 'town')) {
db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_${map.slug}_2`, `key-${map.slug}-2`);
}
const fresh = (offset: number) => { const fresh = (offset: number) => {
vi.setSystemTime(base + offset); const at = new Date().toISOString(); vi.setSystemTime(base + offset); const at = new Date().toISOString();
db().prepare('UPDATE servers SET container_checked_at=?,last_seen_at=?,last_checked_at=?').run(at, at, at); db().prepare('UPDATE servers SET container_checked_at=?,last_seen_at=?,last_checked_at=?').run(at, at, at);
db().prepare('UPDATE ec_hosts SET last_heartbeat=?').run(at); db().prepare('UPDATE ec_hosts SET last_heartbeat=?').run(at);
}; };
fresh(0); fresh(0);
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token); const saved = scaling.saveScaling(policySettings, undefined, undefined, scaling.previewScaling(policySettings).token);
for (const offset of [0, 30_000, 60_000]) { fresh(offset); await scaling.runAutoscaling(); } for (const offset of [0, 30_000, 60_000]) { fresh(offset); await scaling.runAutoscaling(); }
const intent = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string }; const intent = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
expect(intent?.operation_id).toBeTruthy(); expect(intent?.operation_id).toBeTruthy();
@@ -103,3 +110,10 @@ it('rejects expired, changed revision, and newly protected removal authorization
s.fresh(130_000); s.fresh(130_000);
expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(false); expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(false);
}); });
it('authorizes a game-wide removal without trimming any map below its targets', async () => {
const s = await setup(true);
expect(s.db().prepare("SELECT server FROM server_deployments WHERE id=?").get(s.operationId)).toEqual({ server: names[0] });
const command = await s.dispatch();
expect(s.authorization.claimRemovalAuthorization(s.hostId, command.id, command.payload).authorized).toBe(true);
});
+1
View File
@@ -20,6 +20,7 @@ it('separates variants, deduplicates pending identities, and initializes additiv
expect(scalingSnapshot(policy).members.map(m => [m.name, m.state])).toEqual([[town, 'empty']]); expect(scalingSnapshot(policy).members.map(m => [m.name, m.state])).toEqual([[town, 'empty']]);
db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('create',?,'create','queued','Awaiting placement',?,'test')`).run(town, at); db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES('create',?,'create','queued','Awaiting placement',?,'test')`).run(town, at);
db().prepare(`INSERT INTO server_creation_jobs(deployment_id,server,game_mode,map_slug,expected_map) VALUES('create',?,'zombie','town','zm_transit')`).run(town); db().prepare(`INSERT INTO server_creation_jobs(deployment_id,server,game_mode,map_slug,expected_map) VALUES('create',?,'zombie','town','zm_transit')`).run(town);
expect(scalingSnapshot({ ...policy, mapSlug: '*' }).members.map(m => m.name).sort()).toEqual([farm, town]);
const snapshot = scalingSnapshot(policy); const snapshot = scalingSnapshot(policy);
expect(snapshot.members).toHaveLength(1); expect(snapshot.members).toHaveLength(1);
expect(snapshot.members[0].state).toBe('creating'); expect(snapshot.members[0].state).toBe('creating');