495 lines
28 KiB
TypeScript
495 lines
28 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
|
komodoRestart: vi.fn(), komodoInspectHealthy: vi.fn()
|
|
}));
|
|
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
|
rconGetPlayerCount: vi.fn(), rconStatusResult: vi.fn()
|
|
}));
|
|
|
|
let fixture: Fixture;
|
|
const servers = ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_theater_3'];
|
|
const healthyRcon = {
|
|
ok: true as const, endpoint: { host: 'test', port: 28960 }, rawReply: '',
|
|
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.resetAllMocks();
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(new Date('2026-09-17T15:00:00Z'));
|
|
fixture = await newFixture(servers);
|
|
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
|
|
vi.stubEnv('KOMODO_API_KEY', 'test');
|
|
vi.stubEnv('KOMODO_API_SECRET', 'test');
|
|
vi.stubEnv('RCON_PASSWORD', 'test');
|
|
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '1');
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
await seedServerRegistry();
|
|
const { komodoRestart, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
|
vi.mocked(komodoRestart).mockResolvedValue({ ok: true });
|
|
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
|
vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
|
|
vi.mocked(rconStatusResult).mockResolvedValue(healthyRcon);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fixture.cleanup();
|
|
vi.unstubAllEnvs();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
function nextTick() { vi.setSystemTime(Date.now() + 15_000); }
|
|
|
|
it('ignores a late restart reply after deletion without changing a replacement recovery slot', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
|
const { restartInRollout } = await import('../../src/lib/server/provisioning/restarts.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
let complete!: (value: { ok: true }) => void;
|
|
vi.mocked(komodoRestart).mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
|
|
const oldOperation = beginDeployment(servers[0], 'restart');
|
|
const pending = restartInRollout(servers[0], { operationId: oldOperation, action: 'restart' });
|
|
db().prepare('DELETE FROM servers WHERE name = ?').run(servers[0]);
|
|
await seedServerRegistry();
|
|
const replacement = beginDeployment(servers[0], 'restart');
|
|
const requestedAt = new Date().toISOString();
|
|
db().prepare('INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)')
|
|
.run(servers[0], requestedAt, replacement);
|
|
nextTick();
|
|
complete({ ok: true });
|
|
expect(await pending).toMatchObject({ kind: 'failed' });
|
|
expect(db().prepare('SELECT state, server_deleted_at FROM server_deployments WHERE id = ?').get(oldOperation))
|
|
.toMatchObject({ state: 'cancelled', server_deleted_at: expect.any(String) });
|
|
expect(db().prepare('SELECT operation_id, requested_at, error FROM restart_rollout WHERE server = ?').get(servers[0]))
|
|
.toEqual({ operation_id: replacement, requested_at: requestedAt, error: null });
|
|
});
|
|
|
|
async function deployment(id = 'dep-1', targets = servers, action = 'promote') {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare('INSERT INTO deployments (id, action, started_at) VALUES (?, ?, ?)')
|
|
.run(id, action, new Date().toISOString());
|
|
for (const server of targets) {
|
|
db().prepare(`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state)
|
|
VALUES (?, ?, 1, 'pending')`).run(id, server);
|
|
}
|
|
}
|
|
|
|
async function rows(id = 'dep-1') {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
return db().prepare('SELECT server, restart_state, restarted_at, error FROM deployment_servers WHERE deployment_id = ? ORDER BY server')
|
|
.all(id) as Array<{ server: string; restart_state: string; restarted_at: string | null; error: string | null }>;
|
|
}
|
|
|
|
async function beta(targets: string[], waitForHealth = true) {
|
|
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
|
const { script } = createScript({ name: 'test script', targetRelPath: 'rollout.gsc', content: Buffer.from('main(){}') });
|
|
return openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: getLatestVersion(script.id)!.id }],
|
|
servers: targets, waitForHealth, waitForMatch: false });
|
|
}
|
|
|
|
describe('rolling deployment restarts', () => {
|
|
it('restarts one server per recovery and finishes only after the last server is healthy', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect((await sweep()).restarted).toBe(1);
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[0]]]);
|
|
expect((await rows()).map((row) => !!row.restarted_at)).toEqual([true, false, false]);
|
|
await sweep(); // Repeated kick can't bypass the grace period.
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
expect(await sweep()).toMatchObject({ restarted: 1, healthy: 1 });
|
|
expect((await rows()).map((row) => row.restart_state)).toEqual(['done', 'pending', 'pending']);
|
|
nextTick();
|
|
expect(await sweep()).toMatchObject({ restarted: 1, healthy: 1 });
|
|
nextTick();
|
|
expect(await sweep()).toMatchObject({ restarted: 0, healthy: 1 });
|
|
expect((await rows()).every((row) => row.restart_state === 'done')).toBe(true);
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual(servers.map((server) => [server]));
|
|
expect(db().prepare('SELECT finished_at FROM deployments WHERE id = ?').get('dep-1'))
|
|
.toMatchObject({ finished_at: expect.any(String) });
|
|
});
|
|
|
|
it('shares capacity across promote, rollback, and drift deployments in oldest-first order', async () => {
|
|
await deployment('z-oldest', [servers[2]], 'rollback');
|
|
nextTick();
|
|
await deployment('a-next', [servers[0]], 'drift');
|
|
nextTick();
|
|
await deployment('b-latest', [servers[1]], 'promote');
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[2]]]);
|
|
nextTick();
|
|
await sweep();
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[2]], [servers[0]]]);
|
|
});
|
|
|
|
it('skips occupied and unreachable servers without consuming capacity', async () => {
|
|
await deployment();
|
|
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
|
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(4).mockResolvedValueOnce(null).mockResolvedValueOnce(0);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[2]]]);
|
|
});
|
|
|
|
it('keeps the fleet queued while either container or RCON health is failing', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
|
await sweep();
|
|
nextTick();
|
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'starting' });
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: false, endpoint: null, reason: 'timeout', detail: 'starting' });
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('coalesces overlapping sweeps while the remote restart is unresolved', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
let finish!: () => void;
|
|
const entered = new Promise<void>((resolve) => {
|
|
vi.mocked(komodoRestart).mockImplementationOnce(() => {
|
|
resolve();
|
|
return new Promise((done) => { finish = () => done({ ok: true }); });
|
|
});
|
|
});
|
|
const first = sweep();
|
|
await entered;
|
|
const second = sweep();
|
|
expect(second).toBe(first);
|
|
// Even a long-running restart request cannot be freed by a health poll.
|
|
nextTick();
|
|
const { refreshRestartRollout, rolloutWaitReason } = await import('../../src/lib/server/pipeline/restart-rollout.js');
|
|
await refreshRestartRollout();
|
|
expect(rolloutWaitReason(servers[1])).toMatch(/Queued/);
|
|
finish();
|
|
await Promise.all([first, second]);
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('deduplicates simultaneous deployment rows for the same server', async () => {
|
|
await deployment('dep-1', [servers[0]]);
|
|
await deployment('dep-2', [servers[0], servers[1]]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
expect((await rows('dep-1'))[0].restarted_at).toBeTruthy();
|
|
expect((await rows('dep-2'))[0].restarted_at).toBeTruthy();
|
|
});
|
|
|
|
it('does not count a later deployment as having been applied by an earlier restart', async () => {
|
|
await deployment('dep-1', [servers[0]]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
await deployment('dep-2', [servers[0]]);
|
|
nextTick();
|
|
await sweep();
|
|
expect((await rows('dep-1'))[0].restart_state).toBe('done');
|
|
expect((await rows('dep-2'))[0].restart_state).toBe('pending');
|
|
// Provisioning can submit the next job in the pass that verifies the
|
|
// first, but it must issue a distinct restart and await its recovery.
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
expect((await rows('dep-2'))[0].restarted_at).not.toBe((await rows('dep-1'))[0].restarted_at);
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
expect((await rows('dep-2'))[0].restarted_at).toBeTruthy();
|
|
});
|
|
|
|
it('preserves occupied slots across app/module restarts', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
await sweep();
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
vi.resetModules();
|
|
const fresh = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await fresh.sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
await fresh.sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('adopts pre-upgrade restarts instead of starting a second server', async () => {
|
|
await deployment();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare('UPDATE deployment_servers SET restarted_at = ? WHERE server = ?')
|
|
.run(new Date().toISOString(), servers[0]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(komodoRestart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retains capacity after a timeout because the remote restart may have been accepted', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
|
vi.mocked(komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'unreachable', message: 'timed out' });
|
|
await sweep();
|
|
expect((await rows())[0].restart_state).toBe('failed');
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'restarting' });
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not restart a server whose file deployment failed', async () => {
|
|
await deployment();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare("UPDATE deployment_servers SET files_changed = 0, restart_state = 'failed', error = 'write failed' WHERE server = ?")
|
|
.run(servers[0]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[1]]]);
|
|
expect((await rows())[0].error).toBe('write failed');
|
|
});
|
|
|
|
it('releases slots for definite local failures that could not have started a restart', async () => {
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
vi.mocked(komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'no-name', message: 'no komodo_name' });
|
|
await sweep();
|
|
expect((await rows())[0].restart_state).toBe('failed');
|
|
expect(vi.mocked(komodoRestart).mock.calls).toEqual([[servers[0]], [servers[1]]]);
|
|
});
|
|
});
|
|
|
|
describe('beta and deployment coordination', () => {
|
|
it('limits beta restarts and releases capacity only after recovery', async () => {
|
|
await beta(servers);
|
|
const { betaTick } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const first = betaTick();
|
|
expect(betaTick()).toBe(first);
|
|
await first;
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
await betaTick();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('shares the same capacity when beta and deployment ticks run concurrently', async () => {
|
|
await beta([servers[1]]);
|
|
await deployment('dep-1', [servers[0]]);
|
|
const { betaTick } = await import('../helpers/provisioning.js');
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await Promise.all([sweep(), betaTick()]);
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retains beta recovery slots even when the beta skips health checks or is closed', async () => {
|
|
const { betaId } = await beta([servers[0]], false);
|
|
const { betaTick } = await import('../helpers/provisioning.js');
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
await betaTick();
|
|
expect(db().prepare('SELECT stage FROM beta_servers WHERE beta_id = ?').get(betaId)).toEqual({ stage: 'ready' });
|
|
db().prepare("UPDATE betas SET state = 'promoted' WHERE id = ?").run(betaId);
|
|
await deployment('dep-1', [servers[1]]);
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
nextTick();
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('checks player occupancy again when resuming a legacy restarting beta', async () => {
|
|
const { betaId } = await beta([servers[0]]);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare("UPDATE beta_servers SET stage = 'restarting', updated_at = ? WHERE beta_id = ?")
|
|
.run(new Date(Date.now() - 30_000).toISOString(), betaId);
|
|
const { betaTick } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
|
vi.mocked(rconGetPlayerCount).mockResolvedValue(3);
|
|
await betaTick();
|
|
expect(komodoRestart).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Provisioning ownership', () => {
|
|
it('persists caller requests before probes and completes them without another caller tick', async () => {
|
|
await deployment('campaign', [servers[0]]);
|
|
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
|
|
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
|
const { provisioningOperation, getDeployment } = await import('../../src/lib/server/provisioning/index.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { komodoRestart, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
|
await sweep(); await sweep();
|
|
const source = db().prepare('SELECT provisioning_id FROM deployment_servers WHERE deployment_id = ?').get('campaign') as { provisioning_id: string };
|
|
const id = source.provisioning_id;
|
|
expect(provisioningOperation(id)).toMatchObject({ action: 'restart', state: 'waiting', submitted_at: null });
|
|
expect(getDeployment(id)?.sources).toEqual([{ kind: 'campaign', source_id: 'campaign', server: servers[0],
|
|
label: 'Promotion campaign', href: '/campaigns/campaign' }]);
|
|
expect(komodoRestart).not.toHaveBeenCalled();
|
|
expect(komodoInspectHealthy).not.toHaveBeenCalled();
|
|
expect(rconGetPlayerCount).not.toHaveBeenCalled();
|
|
expect(db().prepare('SELECT COUNT(*) n FROM server_deployments').get()).toEqual({ n: 1 });
|
|
await processProvisioning(); nextTick(); await processProvisioning();
|
|
expect(provisioningOperation(id)?.state).toBe('done');
|
|
expect((await rows('campaign'))[0].restart_state).toBe('pending');
|
|
await sweep();
|
|
expect((await rows('campaign'))[0].restart_state).toBe('done');
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('lets concurrent callers submit while only the owner admits restart capacity', async () => {
|
|
await deployment('campaign', [servers[0]]); await beta([servers[1]]);
|
|
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
|
|
const { betaTick } = await import('../../src/lib/server/pipeline/beta-runner.js');
|
|
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
|
await Promise.all([sweep(), betaTick()]);
|
|
expect(komodoRestart).not.toHaveBeenCalled(); expect(rconGetPlayerCount).not.toHaveBeenCalled();
|
|
const first = processProvisioning();
|
|
expect(processProvisioning()).toBe(first);
|
|
await first;
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not replay an ambiguous campaign restart after recovery or app restart', async () => {
|
|
await deployment('campaign', [servers[0]]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
vi.mocked(komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'unreachable', message: 'timeout' });
|
|
await sweep(); nextTick(); await sweep();
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb(); vi.resetModules();
|
|
const fresh = await import('../helpers/provisioning.js');
|
|
nextTick(); await fresh.sweep();
|
|
expect((await rows('campaign'))[0].restart_state).toBe('failed');
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('holds a paused beta request and cancels closed work without a beta runner tick', async () => {
|
|
const { betaId } = await beta([servers[0]]);
|
|
const { betaTick } = await import('../../src/lib/server/pipeline/beta-runner.js');
|
|
await betaTick();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare("INSERT INTO beta_test_plans (beta_id, plan, outcome) VALUES (?, '{}', 'paused')").run(betaId);
|
|
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
|
await processProvisioning();
|
|
expect(db().prepare('SELECT state, stage FROM server_deployments').get()).toEqual({ state: 'waiting', stage: 'Waiting for the requester to resume' });
|
|
expect(rconGetPlayerCount).not.toHaveBeenCalled();
|
|
db().prepare("UPDATE betas SET state = 'rolled-back' WHERE id = ?").run(betaId);
|
|
await processProvisioning();
|
|
expect(db().prepare('SELECT state FROM server_deployments').get()).toEqual({ state: 'cancelled' });
|
|
expect(komodoRestart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows an explicit new operation after failed campaign recovery without changing the failed history', async () => {
|
|
await deployment('campaign', [servers[0]]);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
vi.mocked(komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'unreachable', message: 'timeout' });
|
|
await sweep(); nextTick(); await sweep();
|
|
const { requestLifecycle } = await import('../../src/lib/server/provisioning/index.js');
|
|
expect(requestLifecycle(servers[0], 'restart').ok).toBe(true);
|
|
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
|
await processProvisioning();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
expect((await rows('campaign'))[0].restart_state).toBe('failed');
|
|
});
|
|
|
|
it('does not credit a legacy submission with an older completed recovery slot', async () => {
|
|
await deployment('campaign', [servers[0]]);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const old = new Date().toISOString(); nextTick();
|
|
const submitted = new Date().toISOString();
|
|
db().prepare('INSERT INTO restart_rollout (server, requested_at, healthy_at) VALUES (?, ?, ?)').run(servers[0], old, old);
|
|
db().prepare('UPDATE deployment_servers SET restarted_at = ?').run(submitted);
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(db().prepare('SELECT state FROM server_deployments').get()).toEqual({ state: 'awaiting-health' });
|
|
expect(db().prepare('SELECT requested_at, healthy_at FROM restart_rollout').get()).toEqual({ requested_at: submitted, healthy_at: null });
|
|
nextTick(); await sweep();
|
|
expect((await rows('campaign'))[0].restart_state).toBe('done');
|
|
expect(komodoRestart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves a queued request across app restarts and links explicit beta retries to new history', async () => {
|
|
const { betaId } = await beta([servers[0]]);
|
|
const { betaTick } = await import('../../src/lib/server/pipeline/beta-runner.js');
|
|
await betaTick();
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const previous = db().prepare('SELECT id FROM server_deployments').get() as { id: string };
|
|
closeDb(); vi.resetModules();
|
|
const { requestRestart, releaseRestartRequest, provisioningOperation } = await import('../../src/lib/server/provisioning/index.js');
|
|
const source = { kind: 'beta' as const, id: String(betaId) };
|
|
expect(requestRestart(servers[0], source).id).toBe(previous.id);
|
|
releaseRestartRequest(servers[0], source);
|
|
const next = requestRestart(servers[0], source);
|
|
expect(next.id).not.toBe(previous.id);
|
|
expect(provisioningOperation(previous.id)?.state).toBe('cancelled');
|
|
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
|
await processProvisioning();
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
expect(komodoRestart).toHaveBeenCalledTimes(1);
|
|
expect(provisioningOperation(next.id)?.state).toBe('awaiting-health');
|
|
});
|
|
});
|
|
|
|
describe('rollout configuration', () => {
|
|
it('honors a configured limit larger than one', async () => {
|
|
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
|
saveFleetSettings({ ...fleetSettings(), restartConcurrency: 2 });
|
|
await deployment();
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('holds new restarts after lowering a live limit without releasing active slots', async () => {
|
|
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
|
const { sweep } = await import('../helpers/provisioning.js');
|
|
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
|
saveFleetSettings({ ...fleetSettings(), restartConcurrency: 2 });
|
|
await deployment();
|
|
await sweep();
|
|
saveFleetSettings({ ...fleetSettings(), restartConcurrency: 1 });
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(2);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT server FROM restart_rollout WHERE healthy_at IS NULL').all()).toHaveLength(2);
|
|
nextTick();
|
|
await sweep();
|
|
expect(komodoRestart).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|