Rebuild beta and campaign file activations

This commit is contained in:
2026-10-12 22:52:58 -04:00
parent fa0baa8db5
commit 537b9841a7
22 changed files with 533 additions and 94 deletions
+121 -3
View File
@@ -39,7 +39,7 @@ const inventory = () => ({
memoryTotal: 64 * 1024 ** 3,
memoryAvailable: 60 * 1024 ** 3,
protocolVersion: 1,
capabilities: ['instance.unhealthy-recovery'],
capabilities: ['instance.unhealthy-recovery', 'instance.rebuild-files'],
dockerRunning: true,
directoriesWritable: true,
storageAvailable: 100_000_000_000,
@@ -1725,6 +1725,96 @@ describe('native instance provisioning', () => {
const { getHost } = await import('../../src/lib/server/hosts/index.js');
expect(getHost(hostId)?.mode).toBe('active');
});
it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
for (const path of ['replace.dll', 'remove.dll']) {
const file = createScript({
name: path,
kind: 'plugin',
targetRelPath: path,
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('promoted plugin')
});
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(file.version.id, file.script.id);
}
const job = await queue();
await advance();
await acknowledge(job.deploymentId);
db()
.prepare(
"INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)"
)
.run(new Date().toISOString());
db()
.prepare(
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('files-rebuild',?,4,'pending')"
)
.run(job.name);
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const changes = [
{ kind: 'plugin', relPath: 'replace.dll', content: Buffer.from('requested plugin') },
{ kind: 'plugin', relPath: 'remove.dll', content: null },
{ kind: 'script', relPath: 'new.gsc', content: Buffer.from('new script') },
{ kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') }
] as const;
for (const change of changes)
queueFileChange({
sourceKind: 'campaign',
sourceId: 'files-rebuild',
server: job.name,
inventorySource: 'campaign',
...change
});
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const rebuild = requestRestart(job.name, { kind: 'campaign', id: 'files-rebuild' });
await advance();
const { getHostOperation, reportHostOperation } =
await import('../../src/lib/server/hosts/index.js');
const staged = db()
.prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?')
.get(rebuild.id) as { stage_operation_id: string };
reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed' });
await advance();
const operation = (await operationFor(rebuild.id))!;
expect(operation.kind).toBe('instance.rebuild');
expect(operation.payload).not.toHaveProperty('unhealthyRecovery');
const { recipeForDeployment } =
await import('../../src/lib/server/fleet/launch-recipes.js');
const recipe = recipeForDeployment(rebuild.id)!.recipe;
for (const change of changes) {
const actual = recipe.files.find(
(file) => file.kind === change.kind && file.path === change.relPath
);
if (change.content === null) expect(actual).toBeUndefined();
else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content);
}
const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{
id: number;
path: string;
}>;
reportHostOperation(hostId, operation.id, {
state: 'completed',
result: { before: files.map((file) => ({ ...file, contentBase64: null })) }
});
remote.state = 'unhealthy';
await advance();
expect((await operationFor(rebuild.id))!.kind).toBe('instance.restart');
await acknowledge(rebuild.id);
const recovery = (await operationFor(rebuild.id))!;
expect(recovery.kind).toBe('instance.rebuild');
expect(recovery.payload).not.toHaveProperty('stageId');
expect(recovery.payload.files).toEqual(operation.payload.files);
remote.state = 'running';
await acknowledge(rebuild.id);
expect((await detail(rebuild.id)).state).toBe('done');
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(3);
});
it('stages campaign bytes while occupied and records agent before images only after safe activation', async () => {
const job = await queue();
await advance();
@@ -1761,6 +1851,19 @@ describe('native instance provisioning', () => {
});
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const restart = requestRestart(job.name, { kind: 'campaign', id: 'campaign' });
expect(restart.action).toBe('rebuild');
// Upgrade queued work from older controllers, but wait for a capable host.
db().prepare("UPDATE server_deployments SET action='restart' WHERE id=?").run(restart.id);
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
heartbeat(hostId, { ...inventory(), capabilities: [] });
await advance();
expect(await detail(restart.id)).toMatchObject({
action: 'rebuild',
state: 'waiting',
stage: expect.stringContaining('Update the host agent')
});
expect(await operationFor(restart.id)).toBeNull();
heartbeat(hostId, inventory());
remote.players = 2;
await advance();
const staged = db()
@@ -1811,9 +1914,18 @@ describe('native instance provisioning', () => {
await advance();
const activation = (await operationFor(restart.id))!;
expect(activation).toMatchObject({
kind: 'instance.restart',
kind: 'instance.rebuild',
generation: 1,
payload: { stageId: restart.id }
payload: {
stageId: restart.id,
nextGeneration: 2,
files: expect.arrayContaining([
expect.objectContaining({
path: 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc',
sha256: createHash('sha256').update(after).digest('hex')
})
])
}
});
expect(
db()
@@ -3819,6 +3931,12 @@ describe('cancelled native mutation bookkeeping', () => {
])
).rejects.toThrow();
const activation = (await operationFor(restart.id))!;
expect(activation.kind).toBe('instance.rebuild');
expect(activation.payload).toMatchObject({
stageId: restart.id,
nextGeneration: activation.generation! + 1
});
expect(activation.payload).not.toHaveProperty('unhealthyRecovery');
reportHostOperation(hostId, activation.id, {
state: 'completed',
result: {