Defer promotion snapshots to campaign activation

This commit is contained in:
2026-10-06 00:16:43 -04:00
parent d6ff59b129
commit 9fd448339d
15 changed files with 179 additions and 56 deletions
+96 -3
View File
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { activateFiles } from '../helpers/staged-files.js';
@@ -13,7 +14,7 @@ beforeEach(async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); vi.unstubAllEnvs(); });
async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'map' = 'game') {
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
@@ -31,6 +32,92 @@ async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'ma
const destination = () => join(fixture.t5, servers[1], 'runtime/plutonium/storage/t5/raw/scripts/sp/review.gsc');
describe('reviewed beta promotion', () => {
it('upgrades existing campaign history without marking known snapshots pending', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments (id, action, started_at) VALUES ('legacy', 'promote', ?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
.run(servers[0], Buffer.from('original'));
closeDb();
const legacy = new Database(fixture.dbPath);
try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); }
finally { legacy.close(); }
expect(db().prepare('SELECT snapshot_pending, content_before FROM deployment_files').get())
.toEqual({ snapshot_pending: 0, content_before: Buffer.from('original') });
});
it.each(['script', 'baseline'] as const)('queues %s promotion without contacting additional host agents', async kind => {
const { betaId } = await ready(kind);
const { prepareNativeHost } = await import('../helpers/native-host.js');
await prepareNativeHost([servers[1]]);
const native = await import('../../src/lib/server/backends/native.js');
const request = vi.spyOn(native, 'nativeRequest').mockRejectedValue(new Error('Agent unavailable'));
const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await promoteBeta(betaId, previewBetaPromotion(betaId).token);
expect(result.awaitingActivation).toBe(1);
expect(request).not.toHaveBeenCalled();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT snapshot_pending FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(result.deploymentId, servers[1])).toEqual({ snapshot_pending: 1 });
});
it.each([
{ kind: 'script', before: 'changed while queued' },
{ kind: 'baseline', before: 'configuration changed while queued' },
{ kind: 'script', before: '' },
{ kind: 'script', before: null }
] as const)('captures activation-time bytes and restores them: %j', async ({ kind, before }) => {
const { betaId } = await ready(kind);
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
const path = kind === 'script' ? destination() : join(destOfKind(servers[1], 'config'), configFileOf(servers[1]));
const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await promoteBeta(betaId);
const { db } = await import('../../src/lib/server/db/index.js');
const file = db().prepare('SELECT id FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(result.deploymentId, servers[1]) as { id: number };
const { GET: diff } = await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js');
const params = { id: result.deploymentId, fileId: String(file.id) };
expect((await diff({ params })).status).toBe(409);
// An edit after confirmation must be the rollback target, not an older preview.
if (before !== null) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, before);
}
await activateFiles('campaign', result.deploymentId);
expect(db().prepare('SELECT snapshot_pending, verb, content_before FROM deployment_files WHERE id = ?').get(file.id))
.toEqual({ snapshot_pending: 0, verb: before === null ? 'added' : 'updated', content_before: before === null ? null : Buffer.from(before) });
expect(db().prepare('SELECT message FROM campaign_events WHERE deployment_id = ?').all(result.deploymentId))
.toContainEqual({ message: `${servers[1]}: captured rollback snapshot for ${kind === 'script' ? 'review.gsc' : configFileOf(servers[1])}` });
expect((await (await diff({ params })).json()).before).toBe(before);
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const rollback = await rollbackPromoteDeployment(result.deploymentId);
await activateFiles('campaign', rollback.deploymentId);
if (before === null) expect(existsSync(path)).toBe(false);
else expect(await readFile(path, 'utf8')).toBe(before);
});
it('withdraws unactivated changes without needing snapshots, preserving existing files', async () => {
const { betaId } = await ready();
await writeFile(destination(), 'untouched');
const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await promoteBeta(betaId);
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const rollback = await rollbackPromoteDeployment(result.deploymentId);
expect(rollback.affectedServers).not.toContain(servers[1]);
await activateFiles('campaign', rollback.deploymentId);
expect(await readFile(destination(), 'utf8')).toBe('untouched');
});
it('blocks rollback without mutation when activation has not confirmed its snapshot', async () => {
const { betaId } = await ready();
const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await promoteBeta(betaId);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE staged_file_changes SET state = 'applying' WHERE source_id = ?").run(result.deploymentId);
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
await expect(rollbackPromoteDeployment(result.deploymentId)).rejects.toMatchObject({ kind: 'snapshot-pending' });
expect(db().prepare('SELECT rolled_back_by FROM deployments WHERE id = ?').get(result.deploymentId)).toEqual({ rolled_back_by: null });
expect(db().prepare("SELECT COUNT(*) n FROM deployments WHERE action = 'rollback'").get()).toEqual({ n: 0 });
});
it('previews exact versions and additional targets without writing, then links the activation campaign', async () => {
const { betaId, script, version } = await ready();
const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
@@ -42,6 +129,12 @@ describe('reviewed beta promotion', () => {
const result = await promoteBeta(betaId, preview.token);
expect(result).toMatchObject({ awaitingActivation: 1, failedServers: [] });
expect(result.deploymentId).toContain('promote');
const { GET: campaign } = await import('../../src/routes/api/campaigns/[id]/+server.js');
const summary = await (await campaign({ params: { id: result.deploymentId } })).json();
expect(summary.files).toEqual(expect.arrayContaining([
expect.objectContaining({ server: servers[0], snapshot_pending: 0 }),
expect.objectContaining({ server: servers[1], snapshot_pending: 1 })
]));
expect(existsSync(destination())).toBe(false);
await activateFiles('campaign', result.deploymentId);
expect(await readFile(destination(), 'utf8')).toBe('main(){}');
+3 -3
View File
@@ -697,7 +697,7 @@ describe('native instance provisioning', () => {
const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(job.name);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after) VALUES('campaign',?,'updated','hud.gsc',?)").run(job.name, after).lastInsertRowid);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)").run(job.name, after).lastInsertRowid);
const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
@@ -714,13 +714,13 @@ describe('native instance provisioning', () => {
remote.players = 0; await advance();
const activation = (await operationFor(restart.id))!;
expect(activation).toMatchObject({ kind: 'instance.restart', generation: 1, payload: { stageId: restart.id } });
expect(db().prepare('SELECT content_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null });
expect(db().prepare('SELECT content_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, snapshot_pending: 1 });
const files = stage.payload.files as Array<{ id: number; path: string }>;
reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } });
await advance();
expect((await detail(restart.id)).state).toBe('done');
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied');
expect(db().prepare('SELECT content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex') });
expect(db().prepare('SELECT content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe(createHash('sha256').update(after).digest('hex'));
});
+8 -1
View File
@@ -59,8 +59,15 @@ describe('operation progress', () => {
expect(campaignFlow([{ restart_state: 'failed', files_changed: 0, restarted_at: null }], false)[0].state).toBe('failed');
});
it('requires health verification after campaign restart and preserves manual work', () => {
expect(campaignFlow([{ restart_state: 'pending', files_changed: 1, restarted_at: 'now' }], false).map(s => s.state)).toEqual(['done', 'done', 'done', 'current']);
expect(campaignFlow([{ restart_state: 'pending', files_changed: 1, restarted_at: 'now' }], false).map(s => s.state)).toEqual(['done', 'done', 'done', 'done', 'current']);
expect(campaignFlow([{ restart_state: 'manual', files_changed: 1, restarted_at: null }], true).some(s => s.state === 'current')).toBe(false);
expect(campaignFlow([{ restart_state: 'done', files_changed: 1, restarted_at: 'now' }], true).every(s => s.state === 'done')).toBe(true);
});
it('shows snapshot confirmation before file application and restart', () => {
const servers = [{ server: 'test', restart_state: 'pending', files_changed: 1, restarted_at: null }];
const files = [{ server: 'test', snapshot_pending: 1, apply_state: 'applying' }];
expect(campaignFlow(servers, false, files)[1]).toEqual({ label: 'Capture rollback snapshots', state: 'current' });
expect(campaignFlow(servers, false, [{ ...files[0], snapshot_pending: 0 }])[2].state).toBe('current');
expect(campaignFlow(servers, false, [{ ...files[0], snapshot_pending: 0, apply_state: 'applied' }])[3].state).toBe('current');
});
});