Restrict launches to prepared runtimes (Refs #93)
CI / checks (push) Successful in 4m53s

This commit is contained in:
2026-10-09 17:15:15 -04:00
parent f0afd6de67
commit 596b160be3
36 changed files with 347 additions and 116 deletions
+55 -42
View File
@@ -18,7 +18,7 @@ const inventory: HostInventory = {
dockerRunning: true,
directoriesWritable: true,
storageAvailable: 100 * 1024 ** 3,
runtimeImages: [],
preparedRuntimeImages: [],
libraries: [],
capabilities: ['host.decommission']
};
@@ -123,7 +123,7 @@ describe('automatic host preparation', () => {
const t5 = await publish('t5');
const t6 = await publish('t6');
const readyInventory = {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [t5, t6].map((version) => ({ id: version.id, state: 'ready' as const })),
capabilities: [
...inventory.capabilities!,
@@ -195,7 +195,7 @@ describe('automatic host preparation', () => {
it('requires both games when enabling even if one library is already cached', async () => {
const version = await publish('t5');
const id = enroll({
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: version.id, state: 'ready' }]
});
verified(id, version);
@@ -212,7 +212,7 @@ describe('automatic host preparation', () => {
const t5 = await publish('t5');
const t6 = await publish('t6');
const readyInventory = {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [t5, t6].map((version) => ({ id: version.id, state: 'ready' as const }))
};
const id = enroll(readyInventory);
@@ -310,7 +310,11 @@ describe('automatic host preparation', () => {
expect(operations(host.id)).toHaveLength(1);
vi.setSystemTime(Date.now() + 1);
const preparedInventory = { ...inventory, runtimeImages: [runtime], libraryCaches: [] };
const preparedInventory = {
...inventory,
preparedRuntimeImages: [runtime],
libraryCaches: []
};
const runtimeHeartbeat = await reportHeartbeat({
request: request('heartbeat', { inventory: preparedInventory })
});
@@ -369,7 +373,7 @@ describe('automatic host preparation', () => {
it('presents preparation without queuing work and prepares the configured runtime even when another image exists', async () => {
const version = await publish();
const id = enroll({ runtimeImages: ['another:runtime'] });
const id = enroll({ preparedRuntimeImages: ['another:runtime'] });
const host = hosts.getHost(id)!;
const status = preparation.hostPreparationStatus(host);
expect(status.items.map((item) => item.kind)).toEqual(['runtime', 'library']);
@@ -406,7 +410,7 @@ describe('automatic host preparation', () => {
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
heartbeat(id, { runtimeImages: [runtime] });
heartbeat(id, { preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(2);
const download = operations(id)[0];
@@ -417,7 +421,7 @@ describe('automatic host preparation', () => {
verified(id, version);
hosts.reportHostOperation(id, download.id, { state: 'completed' });
heartbeat(id, {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: version.id, state: 'ready' }]
});
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
@@ -432,7 +436,7 @@ describe('automatic host preparation', () => {
game: 't5',
files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }]
});
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
const expected = new Map([latest, t6].map((version) => [version.id, version]));
const downloaded: string[] = [];
for (let index = 0; index < expected.size; index++) {
@@ -452,7 +456,7 @@ describe('automatic host preparation', () => {
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(index + 1);
heartbeat(id, {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: downloaded.map((id) => ({ id, state: 'ready' }))
});
}
@@ -462,12 +466,15 @@ describe('automatic host preparation', () => {
});
it('picks up publication after enrollment and keeps verified historical versions intact', async () => {
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toEqual([]);
const old = await publish();
verified(id, old);
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
heartbeat(id, {
preparedRuntimeImages: [runtime],
libraries: [{ id: old.id, state: 'ready' }]
});
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
const latest = await publish();
preparation.reconcileHostPreparation(id);
@@ -479,7 +486,7 @@ describe('automatic host preparation', () => {
it('replaces an undelivered automatic download when newer files finish uploading', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const first = operations(id)[0];
expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true });
@@ -503,8 +510,8 @@ describe('automatic host preparation', () => {
async (automatic) => {
const old = await publish();
const image = 'ghcr.io/example/plutainer:queued-version';
const id = enroll({ runtimeImages: [runtime, image] });
const other = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime, image] });
const other = enroll({ preparedRuntimeImages: [runtime] });
const { queueNativeCreation } =
await import('../../src/lib/server/provisioning/native.js');
for (let index = 0; index < 2; index++) {
@@ -521,7 +528,7 @@ describe('automatic host preparation', () => {
).toBe(true);
}
// Also cover pre-existing intents whose image is no longer cached.
heartbeat(id, { runtimeImages: [runtime] });
heartbeat(id, { preparedRuntimeImages: [runtime] });
const latest = await publish();
const status = preparation.hostPreparationStatus(hosts.getHost(id)!);
expect(status.items.filter((item) => item.label === image)).toHaveLength(0);
@@ -555,7 +562,7 @@ describe('automatic host preparation', () => {
vi.setSystemTime(Date.now() + 1);
hosts.reportHostOperation(id, download.id, { state: 'completed' });
heartbeat(id, {
runtimeImages: [runtime, image],
preparedRuntimeImages: [runtime, image],
libraries: [{ id: old.id, state: 'ready' }]
});
preparation.reconcileHostPreparation(id);
@@ -568,7 +575,7 @@ describe('automatic host preparation', () => {
it('retains an undelivered library download for an accepted intent until that intent is cancelled', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const download = operations(id)[0];
const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
@@ -604,7 +611,7 @@ describe('automatic host preparation', () => {
it('finishes a delivered download before following a newer upload under a new operation ID', async () => {
const old = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const [first] = hosts.pendingHostOperations(id);
const latest = await publish();
@@ -613,7 +620,10 @@ describe('automatic host preparation', () => {
expect(hosts.getHostOperation(first.id)?.state).toBe('pending');
verified(id, old);
hosts.reportHostOperation(id, first.id, { state: 'completed' });
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
heartbeat(id, {
preparedRuntimeImages: [runtime],
libraries: [{ id: old.id, state: 'ready' }]
});
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({
state: 'pending',
@@ -688,23 +698,26 @@ describe('automatic host preparation', () => {
it('blocks a library that cannot fit while preserving the host free-space reserve', async () => {
const version = await publish();
const id = enroll({
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
storageAvailable: 1_000 + version.sizeBytes - 1
});
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
expect(preparation.reconcileHostPreparation(id).state).toBe('blocked');
expect(operations(id)).toEqual([]);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: 1_000 + version.sizeBytes });
heartbeat(id, {
preparedRuntimeImages: [runtime],
storageAvailable: 1_000 + version.sizeBytes
});
expect(preparation.reconcileHostPreparation(id).state).toBe('preparing');
expect(operations(id)[0].payload.libraryVersionId).toBe(version.id);
});
it('returns an active download even when its in-progress bytes have consumed free space', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
const operation = libraries.queueLibraryDownload(id, version.id);
heartbeat(id, {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
storageAvailable: hosts.getHost(id)!.minimumFreeBytes
});
expect(libraries.queueLibraryDownload(id, version.id).id).toBe(operation.id);
@@ -715,7 +728,7 @@ describe('automatic host preparation', () => {
it('prepares T6 when the latest T5 upload cannot fit', async () => {
const oversized = await publish();
const small = await publish('t6', 'small');
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 });
const id = enroll({ preparedRuntimeImages: [runtime], storageAvailable: 1_010 });
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(
@@ -731,7 +744,7 @@ describe('automatic host preparation', () => {
it('does not count partial bytes from a different manifest toward download storage', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
libraries.updateHostLibraryCaches(id, [
{
libraryVersionId: version.id,
@@ -741,7 +754,7 @@ describe('automatic host preparation', () => {
}
]);
heartbeat(id, {
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
storageAvailable: hosts.getHost(id)!.minimumFreeBytes + 20
});
expect(preparation.reconcileHostPreparation(id).state).toBe('blocked');
@@ -753,7 +766,7 @@ describe('automatic host preparation', () => {
it('resumes a failed verified-manifest download using only its remaining storage', async () => {
const version = await publish();
const id = enroll({ runtimeImages: [runtime] });
const id = enroll({ preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const operation = operations(id)[0];
libraries.updateHostLibraryCaches(id, [
@@ -768,11 +781,11 @@ describe('automatic host preparation', () => {
hosts.reportHostOperation(id, operation.id, { state: 'failed', error: 'Connection reset' });
const failedAt = Date.now();
const free = hosts.getHost(id)!.minimumFreeBytes + version.sizeBytes - 80;
heartbeat(id, { runtimeImages: [runtime], storageAvailable: free });
heartbeat(id, { preparedRuntimeImages: [runtime], storageAvailable: free });
preparation.reconcileHostPreparation(id);
expect(operations(id)).toHaveLength(1);
vi.setSystemTime(failedAt + 60_000);
heartbeat(id, { runtimeImages: [runtime], storageAvailable: free });
heartbeat(id, { preparedRuntimeImages: [runtime], storageAvailable: free });
expect(preparation.reconcileHostPreparation(id).state).toBe('preparing');
expect(operations(id)).toHaveLength(2);
expect(operations(id)[0].payload.libraryVersionId).toBe(version.id);
@@ -800,7 +813,7 @@ describe('optional host Plutonium cache', () => {
(state) => {
const ready = {
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: 'game-files', state: 'ready' as const }]
};
const id = enroll(ready);
@@ -819,7 +832,7 @@ describe('optional host Plutonium cache', () => {
it('delivers newer deployments before queued cache work and before a cache replay', () => {
const id = enroll({
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: 'game-files', state: 'ready' }]
});
preparation.reconcileHostPreparation(id);
@@ -842,7 +855,7 @@ describe('optional host Plutonium cache', () => {
const ready = {
...inventory,
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: 'game-files', state: 'ready' as const }]
};
const { credential } = hosts.enrollHost(hosts.createEnrollment(host.id).token, ready);
@@ -903,7 +916,7 @@ describe('optional host Plutonium cache', () => {
const version = await publish();
const ready = {
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: version.id, state: 'ready' as const }],
plutoniumCache: { revision: 5354, bytes: 200 }
};
@@ -948,7 +961,7 @@ describe('optional host Plutonium cache', () => {
const pull = operations(id)[0];
expect(pull.kind).toBe('runtime.pull');
hosts.reportHostOperation(id, pull.id, { state: 'completed' });
heartbeat(id, { ...capable, runtimeImages: [runtime] });
heartbeat(id, { ...capable, preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
const download = operations(id)[0];
expect(download.kind).toBe('library.download');
@@ -956,7 +969,7 @@ describe('optional host Plutonium cache', () => {
hosts.reportHostOperation(id, download.id, { state: 'completed' });
const ready = {
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: version.id, state: 'ready' as const }]
};
heartbeat(id, ready);
@@ -1005,7 +1018,7 @@ describe('optional host Plutonium cache', () => {
it('refreshes for newer production releases and reconciles an interrupted refresh without duplicates', async () => {
const ready = {
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
plutoniumCache: { revision: 5354, bytes: 25_000 }
};
const id = enroll(ready);
@@ -1048,7 +1061,7 @@ describe('optional host Plutonium cache', () => {
checkedAt: new Date(Date.now() - 60 * 60_000).toISOString()
});
if (evidence === 'failed') await release(5354, { error: 'Manifest unavailable' });
const id = enroll({ ...capable, runtimeImages: [runtime] });
const id = enroll({ ...capable, preparedRuntimeImages: [runtime] });
preparation.reconcileHostPreparation(id);
expect(operations(id)[0]).toMatchObject({
kind: 'plutonium.prepare',
@@ -1060,7 +1073,7 @@ describe('optional host Plutonium cache', () => {
).toBe('Latest production revision');
const cached = enroll({
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
plutoniumCache: { revision: 5353, bytes: 200 }
});
preparation.reconcileHostPreparation(cached);
@@ -1071,7 +1084,7 @@ describe('optional host Plutonium cache', () => {
it('leaves agents without the cache capability unchanged', async () => {
await release(5355);
const id = enroll({
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
plutoniumCache: { revision: 5354, bytes: 200 }
});
expect(
@@ -1084,7 +1097,7 @@ describe('optional host Plutonium cache', () => {
const version = await publish();
const ready = {
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
libraries: [{ id: version.id, state: 'ready' as const }]
};
const id = enroll(ready);
@@ -1135,7 +1148,7 @@ describe('optional host Plutonium cache', () => {
const version = await publish();
const id = enroll({
...capable,
runtimeImages: [runtime],
preparedRuntimeImages: [runtime],
storageAvailable: 1_000 + version.sizeBytes - 1
});
hosts.updateHost(id, { minimumFreeBytes: 1_000 });