Restrict launches to prepared runtime images
This commit is contained in:
@@ -337,37 +337,35 @@ describe('automatic host preparation', () => {
|
||||
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
|
||||
});
|
||||
|
||||
it.each([false, true])('prepares queued runtime and library versions after newer publication (automatic placement: %s)', async automatic => {
|
||||
it.each([false, true])('prepares queued libraries without pulling custom runtimes (automatic placement: %s)', async automatic => {
|
||||
const old = await publish();
|
||||
const id = enroll({ runtimeImages: [runtime] });
|
||||
const other = enroll({ runtimeImages: [runtime] });
|
||||
const image = 'ghcr.io/example/plutainer:queued-version';
|
||||
const id = enroll({ runtimeImages: [runtime, image] });
|
||||
const other = enroll({ runtimeImages: [runtime] });
|
||||
const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
|
||||
for (let index = 0; index < 2; index++) {
|
||||
expect(queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', libraryId: old.id,
|
||||
hostId: automatic ? undefined : id, image, plutoKey: 'saved-key' }).ok).toBe(true);
|
||||
}
|
||||
// Also cover pre-existing intents whose image is no longer cached.
|
||||
heartbeat(id, { runtimeImages: [runtime] });
|
||||
const latest = await publish();
|
||||
const status = preparation.hostPreparationStatus(hosts.getHost(id)!);
|
||||
expect(status.items.filter(item => item.label === image)).toHaveLength(1);
|
||||
expect(status.items.filter(item => item.label === image)).toHaveLength(0);
|
||||
expect(status.items.filter(item => item.label.includes('Queued version'))).toHaveLength(1);
|
||||
const otherStatus = preparation.hostPreparationStatus(hosts.getHost(other)!);
|
||||
expect(otherStatus.items.some(item => item.label === image)).toBe(automatic);
|
||||
expect(otherStatus.items.some(item => item.label === image)).toBe(false);
|
||||
expect(otherStatus.items.some(item => item.label.includes('Queued version'))).toBe(automatic);
|
||||
expect(operations(id)).toEqual([]);
|
||||
|
||||
preparation.reconcileHostPreparation(id);
|
||||
const pull = operations(id)[0];
|
||||
expect(pull).toMatchObject({ kind: 'runtime.pull', payload: { image } });
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
hosts.reportHostOperation(id, pull.id, { state: 'completed' });
|
||||
heartbeat(id, { runtimeImages: [runtime, image] });
|
||||
preparation.reconcileHostPreparation(id);
|
||||
const download = operations(id)[0];
|
||||
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: old.id, automatic: true } });
|
||||
preparation.reconcileHostPreparation(id);
|
||||
expect(hosts.getHostOperation(download.id)?.state).toBe('pending');
|
||||
expect(operations(id)).toHaveLength(2);
|
||||
expect(operations(id)).toHaveLength(1);
|
||||
preparation.reconcileHostPreparation(other);
|
||||
expect([...operations(id), ...operations(other)].every(operation => operation.kind !== 'runtime.pull')).toBe(true);
|
||||
|
||||
verified(id, old);
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
|
||||
@@ -120,6 +120,7 @@ describe('native creation intents before placement', () => {
|
||||
it('starts work on a ready host when an older pinned intent is waiting for an offline host', async () => {
|
||||
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries'));
|
||||
const image = 'plutainer:test';
|
||||
vi.stubEnv('PLUTAINER_IMAGE', image);
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const offline = hosts.createHost({ name: 'Offline host' });
|
||||
const ready = hosts.createHost({ name: 'Ready host' });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { prepareNativeHost } from '../helpers/native-host.js';
|
||||
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||
|
||||
let fixture: Fixture;
|
||||
let prepared: Awaited<ReturnType<typeof prepareNativeHost>>;
|
||||
const custom = 'registry.example/untrusted:latest';
|
||||
const launch = { game: 't5' as const, gameMode: 'zombie' as const, mapSlug: 'theater', plutoKey: 'saved-key' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
prepared = await prepareNativeHost();
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
|
||||
async function cache(images: string[]) {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const host = hosts.getHost(prepared.host.id)!;
|
||||
hosts.heartbeat(host.id, { ...host.inventory, runtimeImages: images });
|
||||
}
|
||||
|
||||
describe('runtime image launch policy', () => {
|
||||
it.each([false, true])('rejects an uncached custom image at POST /api/servers (automatic: %s)', async automatic => {
|
||||
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||
const response = await POST({ request: new Request('https://controller.test/api/servers', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...launch, image: custom, ...(automatic ? {} : { hostId: prepared.host.id }) })
|
||||
}) });
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ error: expect.stringContaining('prepared by an admin') });
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const table of ['native_creation_intents', 'server_deployments', 'server_keys']) {
|
||||
expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the configured default before it is cached', async () => {
|
||||
await cache([]);
|
||||
const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
|
||||
expect(queueNativeCreation(launch).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts prepared images only on hosts that have them', async () => {
|
||||
await cache([custom]);
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const other = hosts.createHost({ name: 'Unprepared host' });
|
||||
const { nativeLaunchSelection, nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
|
||||
expect(nativeLaunchSelection({ hostId: prepared.host.id, image: custom }, 't5').image).toBe(custom);
|
||||
expect(nativeSelection({ image: custom }, 't5').hostId).toBe(prepared.host.id);
|
||||
expect(() => nativeLaunchSelection({ hostId: other.id, image: custom }, 't5')).toThrow(/prepared by an admin/);
|
||||
hosts.updateHost(prepared.host.id, { mode: 'disabled' });
|
||||
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(/prepared by an admin/);
|
||||
});
|
||||
|
||||
it('requires exact image references and does not treat a queued admin pull as cached', async () => {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
hosts.queueHostOperation(prepared.host.id, 'runtime.pull', { image: custom });
|
||||
const { nativeLaunchSelection } = await import('../../src/lib/server/provisioning/native.js');
|
||||
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(/prepared by an admin/);
|
||||
await cache([custom + '-other']);
|
||||
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(/prepared by an admin/);
|
||||
});
|
||||
|
||||
it('rechecks cached images when submitting a saved single-server preview', async () => {
|
||||
await cache([custom]);
|
||||
const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
|
||||
const preview = previewNativeCreation({ ...launch, image: custom });
|
||||
await cache([]);
|
||||
expect(queueNativeCreation({ ...launch, previewId: preview.id })).toMatchObject({
|
||||
ok: false, message: expect.stringContaining('prepared by an admin')
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects uncached collection images and rechecks saved previews before writing any jobs', async () => {
|
||||
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: 'Image policy', entries: [{ game: 't5', mapSlug: 'theater', count: 2 }] });
|
||||
expect(() => previewCollection(blueprint.id, 'fill', { image: custom })).toThrow(/prepared by an admin/);
|
||||
await cache([custom]);
|
||||
const preview = previewCollection(blueprint.id, 'fill', { image: custom });
|
||||
const keys = Object.fromEntries(preview.servers.map(server => [server.name!, 'saved-key']));
|
||||
await cache([]);
|
||||
expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/prepared by an admin/);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const table of ['server_launch_collections', 'native_creation_intents', 'server_deployments', 'server_keys']) {
|
||||
expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
|
||||
}
|
||||
await cache([custom]);
|
||||
expect(queueCollection({ previewId: preview.id, keys }).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user