Add exclusive map ownership and capacity-aware autoscaling creation
This commit is contained in:
@@ -31,7 +31,7 @@ it('rejects duplicate policies, stale edits and changed previews', async () => {
|
||||
expect(() => scaling.saveScaling(settings, undefined, undefined, preview.token)).toThrow('already exists');
|
||||
expect(() => scaling.previewScaling(settings, saved.id, 0)).toThrow('changed');
|
||||
expect(() => scaling.saveScaling({ ...settings, maximum: 5 }, saved.id, saved.revision, preview.token)).toThrow('preview changed');
|
||||
expect(() => scaling.saveScaling({ ...settings, mode: 'create-only' }, saved.id, saved.revision, scaling.previewScaling({ ...settings, mode: 'create-only' }, saved.id, saved.revision).token)).toThrow('not available');
|
||||
expect(() => scaling.saveScaling({ ...settings, mode: 'create-destroy' }, saved.id, saved.revision, scaling.previewScaling({ ...settings, mode: 'create-destroy' }, saved.id, saved.revision).token)).toThrow('not available');
|
||||
});
|
||||
|
||||
it('pauses evaluations and prunes expired observations without deleting current history', async () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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: 2, mode: 'create-only' };
|
||||
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 n of [3, 8]) db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_town_${n}`, `key-${n}`);
|
||||
const saved = scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
|
||||
return { db, scaling, saved };
|
||||
}
|
||||
|
||||
it('admits one durable keyed create and reuses it across ticks and restart', async () => {
|
||||
const { db, scaling, saved } = await setup();
|
||||
expect(await scaling.runAutoscaling()).toBe(true);
|
||||
await Promise.all([scaling.runAutoscaling(), scaling.runAutoscaling()]);
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb();
|
||||
await scaling.runAutoscaling();
|
||||
expect(db().prepare('SELECT server,state FROM server_deployments WHERE action=\'create\'').all()).toEqual([{ server: 't6_zombie_town_3', state: 'queued' }]);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM autoscaling_decisions WHERE resolved=0').get()).toEqual({ n: 1 });
|
||||
const { operation_id: id } = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
|
||||
const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js');
|
||||
expect(scalingAdmission(id)).toBeNull();
|
||||
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||
expect(getDeployment(id)?.sources).toContainEqual(expect.objectContaining({ kind: 'autoscaling', href: `/autoscaling#${saved.id}` }));
|
||||
});
|
||||
|
||||
it('requires key coverage and withdraws an unsubmitted create when the policy pauses', async () => {
|
||||
const { db, scaling, saved } = await setup();
|
||||
await scaling.runAutoscaling();
|
||||
const off = { ...settings, mode: 'off' };
|
||||
scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
|
||||
expect(db().prepare("SELECT state FROM server_deployments WHERE action='create'").get()).toEqual({ state: 'cancelled' });
|
||||
expect(db().prepare('SELECT owns_map FROM autoscaling_policies').get()).toEqual({ owns_map: 0 });
|
||||
db().prepare("DELETE FROM server_keys WHERE server_id='t6_zombie_town_8'").run();
|
||||
const current = scaling.getScalingPolicy(saved.id)!;
|
||||
expect(scaling.previewScaling(settings, saved.id, current.revision).blockers.join(' ')).toContain('Save keys');
|
||||
});
|
||||
|
||||
it('blocks manual lifecycle and collection creation, including stale previews', async () => {
|
||||
const { db, scaling, saved } = await setup();
|
||||
const { queueCreateServer, destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
|
||||
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
expect(queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town' })).toMatchObject({ ok: false, message: expect.stringContaining('Autoscaling') });
|
||||
expect(destroyServer('t6_zombie_town_3')).toMatchObject({ ok: false, message: expect.stringContaining('Autoscaling') });
|
||||
expect(requestLifecycle('t6_zombie_town_3', 'stop')).toMatchObject({ ok: false, status: 409 });
|
||||
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const blueprint = saveBlueprint({ name: 'Town', entries: [{ game: 't6', mapSlug: 'town', count: 1 }] });
|
||||
expect(previewCollection(blueprint.id).blockers.join(' ')).toContain('Autoscaling');
|
||||
const off = { ...settings, mode: 'off' }; scaling.saveScaling(off, saved.id, saved.revision, scaling.previewScaling(off, saved.id, saved.revision).token);
|
||||
const preview = previewCollection(blueprint.id);
|
||||
const current = scaling.getScalingPolicy(saved.id)!;
|
||||
scaling.saveScaling(settings, saved.id, current.revision, scaling.previewScaling(settings, saved.id, current.revision).token);
|
||||
expect(() => queueCollection({ previewId: preview.id })).toThrow('Autoscaling');
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM server_deployments').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('operator cancellation pauses the policy and does not immediately create a replacement', async () => {
|
||||
const { db, scaling } = await setup();
|
||||
await scaling.runAutoscaling();
|
||||
const { operation_id: id } = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
|
||||
const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js');
|
||||
expect(cancelProvisioningJob(id)).toBe(true);
|
||||
await scaling.runAutoscaling();
|
||||
expect(scaling.scalingReport().policies[0].policy.mode).toBe('off');
|
||||
expect(db().prepare("SELECT COUNT(*) AS n FROM server_deployments WHERE action='create'").get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('exact matching cannot remove an owned map absent from the collection', async () => {
|
||||
const town = 't6_zombie_town_1';
|
||||
await prepareNativeHost([town]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const at = new Date().toISOString();
|
||||
db().prepare("INSERT INTO servers(name,game,map,map_slug,container_state,container_checked_at,last_seen_at,last_players) VALUES(?,'t6','zm_transit','town','running',?,?,0)").run(town, at, at);
|
||||
for (const n of [1, 2]) db().prepare('INSERT INTO server_keys(server_id,pluto_key) VALUES(?,?)').run(`t6_zombie_town_${n}`, `key-${n}`);
|
||||
const scaling = await import('../../src/lib/server/autoscaling/index.js');
|
||||
scaling.saveScaling(settings, undefined, undefined, scaling.previewScaling(settings).token);
|
||||
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const collection = saveBlueprint({ name: 'Moon only', entries: [{ game: 't5', mapSlug: 'moon', count: 1 }] });
|
||||
const preview = previewCollection(collection.id, 'exact');
|
||||
expect(preview.removals.map(r => r.name)).toContain(town);
|
||||
expect(preview.blockers.join(' ')).toContain('Autoscaling');
|
||||
expect(() => queueCollection({ previewId: preview.id })).toThrow('Autoscaling');
|
||||
});
|
||||
|
||||
it('rechecks evidence and global pause before dispatch', async () => {
|
||||
const { db, scaling } = await setup();
|
||||
await scaling.runAutoscaling();
|
||||
const { operation_id: id } = db().prepare('SELECT operation_id FROM autoscaling_decisions WHERE resolved=0').get() as { operation_id: string };
|
||||
const { scalingAdmission } = await import('../../src/lib/server/autoscaling/admission.js');
|
||||
db().prepare("DELETE FROM server_keys WHERE server_id='t6_zombie_town_3'").run();
|
||||
expect(scalingAdmission(id)).toContain('no longer eligible');
|
||||
scaling.setScalingGlobalPause(true);
|
||||
expect(db().prepare('SELECT state FROM server_deployments WHERE id=?').get(id)).toEqual({ state: 'cancelled' });
|
||||
expect(await scaling.runAutoscaling()).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user