Restrict launches to prepared runtime images
This commit is contained in:
+4
-2
@@ -32,7 +32,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.5.0"
|
VERSION = "1.5.1"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1505,7 +1505,9 @@ class Agent:
|
|||||||
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
||||||
name = self.container_name(instance_id, generation)
|
name = self.container_name(instance_id, generation)
|
||||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||||
args = ["container", "create", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
# Do not let Docker fetch a replacement if the prepared image disappears
|
||||||
|
# between validation and container creation.
|
||||||
|
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
||||||
if self.config.get("hostId"):
|
if self.config.get("hostId"):
|
||||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||||
|
|||||||
@@ -727,6 +727,29 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertEqual(second["result"], first["result"])
|
self.assertEqual(second["result"], first["result"])
|
||||||
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
|
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
|
||||||
|
|
||||||
|
def test_creation_uses_cached_runtime_without_allowing_docker_to_pull(self):
|
||||||
|
payload = self.create()
|
||||||
|
creation = next(args for args in self.docker.calls if args[:2] == ("container", "create"))
|
||||||
|
self.assertIn("--pull=never", creation)
|
||||||
|
self.assertEqual(creation[-1], payload["image"])
|
||||||
|
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
||||||
|
|
||||||
|
def test_image_disappearing_after_validation_cannot_be_pulled_or_started(self):
|
||||||
|
original = self.docker.call
|
||||||
|
|
||||||
|
def missing_image(*args, **kwargs):
|
||||||
|
if args[:2] == ("container", "create"):
|
||||||
|
self.assertIn("--pull=never", args)
|
||||||
|
raise AgentError("No such image")
|
||||||
|
return original(*args, **kwargs)
|
||||||
|
|
||||||
|
payload = {"game": "t5", "libraryId": self.library_id, "image": "prepared:runtime", "port": 28960}
|
||||||
|
with patch.object(self.docker, "call", side_effect=missing_image):
|
||||||
|
with self.assertRaisesRegex(AgentError, "No such image"):
|
||||||
|
self.agent.create(INSTANCE, 1, payload)
|
||||||
|
self.assertEqual(self.docker.started, 0)
|
||||||
|
self.assertFalse(any(args[:2] == ("image", "pull") for args in self.docker.calls))
|
||||||
|
|
||||||
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
||||||
self.create()
|
self.create()
|
||||||
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ Status replies still expire normally, and native timeout diagnostics are retaine
|
|||||||
game files, image, capacity and ports required for the launch.
|
game files, image, capacity and ports required for the launch.
|
||||||
6. Launch a server or collection. The latest game files and default runtime
|
6. Launch a server or collection. The latest game files and default runtime
|
||||||
are selected automatically. An advanced runtime override is
|
are selected automatically. An advanced runtime override is
|
||||||
available. If the latest files change after previewing, refresh the preview
|
available for images an admin has already prepared on the destination host.
|
||||||
|
If the latest files change after previewing, refresh the preview
|
||||||
before launching.
|
before launching.
|
||||||
Apply saves the requested lobby IDs. Provisioning selects a host and atomically
|
Apply saves the requested lobby IDs. Provisioning selects a host and atomically
|
||||||
reserves UDP/TCP ports when capacity is available.
|
reserves UDP/TCP ports when capacity is available.
|
||||||
@@ -153,8 +154,12 @@ memory measurements. Hosts with stale or missing measurements cannot accept new
|
|||||||
servers. Existing API clients may still explicitly constrain a launch to a host.
|
servers. Existing API clients may still explicitly constrain a launch to a host.
|
||||||
|
|
||||||
Queued jobs retain their accepted game-file version. Automatic host preparation
|
Queued jobs retain their accepted game-file version. Automatic host preparation
|
||||||
also downloads the files and runtime images needed by unplaced jobs; a newer
|
also downloads the game files needed by unplaced jobs; a newer upload does not
|
||||||
upload does not remove files still needed by the queue.
|
remove files still needed by the queue. Only the configured default runtime is
|
||||||
|
pulled automatically. Custom images must already be cached on the selected host,
|
||||||
|
or on an active host for automatic placement, before a launch is accepted. Admins
|
||||||
|
can prepare them from the host page. Queued launches and migrations never trigger
|
||||||
|
custom image pulls, and container creation cannot implicitly pull a missing image.
|
||||||
|
|
||||||
Only provisioning reserves a host and port. Agent **1.4.6** fixes container
|
Only provisioning reserves a host and port. Agent **1.4.6** fixes container
|
||||||
discovery for resource sampling (the
|
discovery for resource sampling (the
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ const activeStates = ['pending', 'accepted', 'running', 'unknown'];
|
|||||||
const supersededDownload = 'Superseded by newer game files before delivery to the host.';
|
const supersededDownload = 'Superseded by newer game files before delivery to the host.';
|
||||||
type PreparationOperation = { id: string; sequence: number; last_delivered_at: string | null; state: HostOperationState; updated_at: string; error: string | null };
|
type PreparationOperation = { id: string; sequence: number; last_delivered_at: string | null; state: HostOperationState; updated_at: string; error: string | null };
|
||||||
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation };
|
type Target = { item: HostPreparationItem; image?: string; versionId?: string; requiredBytes: number; operation?: PreparationOperation };
|
||||||
type QueuedSelection = { libraryId: string; image: string | null };
|
type QueuedSelection = { libraryId: string };
|
||||||
type LibraryTarget = Pick<ReturnType<typeof latestPublishedLibraries>[number], 'id' | 'sizeBytes' | 'manifestSha256'> & { name: string; queued?: boolean };
|
type LibraryTarget = Pick<ReturnType<typeof latestPublishedLibraries>[number], 'id' | 'sizeBytes' | 'manifestSha256'> & { name: string; queued?: boolean };
|
||||||
|
|
||||||
/** Accepted requests retain their prerequisites until placement owns an instance. */
|
/** Accepted requests retain their prerequisites until placement owns an instance. */
|
||||||
function queuedSelections(hostId: string): QueuedSelection[] {
|
function queuedSelections(hostId: string): QueuedSelection[] {
|
||||||
// Standalone host/library readers can initialize before provisioning migrations.
|
// Standalone host/library readers can initialize before provisioning migrations.
|
||||||
if (!db().prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_creation_intents'").get()) return [];
|
if (!db().prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='native_creation_intents'").get()) return [];
|
||||||
return db().prepare(`SELECT DISTINCT json_extract(q.selection,'$.libraryId') AS libraryId,
|
return db().prepare(`SELECT DISTINCT json_extract(q.selection,'$.libraryId') AS libraryId FROM native_creation_intents q
|
||||||
json_extract(q.selection,'$.image') AS image FROM native_creation_intents q
|
|
||||||
JOIN server_deployments d ON d.id=q.deployment_id
|
JOIN server_deployments d ON d.id=q.deployment_id
|
||||||
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
LEFT JOIN native_provisioning n ON n.deployment_id=d.id
|
||||||
WHERE d.action='create' AND d.server_deleted_at IS NULL AND n.deployment_id IS NULL
|
WHERE d.action='create' AND d.server_deleted_at IS NULL AND n.deployment_id IS NULL
|
||||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
|
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
|
||||||
AND (json_extract(q.selection,'$.automatic')=1 OR json_extract(q.selection,'$.hostId') IS NULL
|
AND (json_extract(q.selection,'$.automatic')=1 OR json_extract(q.selection,'$.hostId') IS NULL
|
||||||
OR json_extract(q.selection,'$.hostId')=?)
|
OR json_extract(q.selection,'$.hostId')=?)
|
||||||
UNION SELECT i.library_id AS libraryId,i.image FROM ec_host_migrations m
|
UNION SELECT i.library_id AS libraryId FROM ec_host_migrations m
|
||||||
JOIN native_instances i ON i.id=m.instance_id JOIN server_deployments d ON d.id=m.deployment_id
|
JOIN native_instances i ON i.id=m.instance_id JOIN server_deployments d ON d.id=m.deployment_id
|
||||||
WHERE m.removed_at IS NULL AND d.server_deleted_at IS NULL
|
WHERE m.removed_at IS NULL AND d.server_deleted_at IS NULL
|
||||||
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
|
AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
|
||||||
@@ -88,8 +87,8 @@ function targets(host: Host): Target[] {
|
|||||||
&& operation.sequence > enabling.operation_floor && operation.state === 'completed'
|
&& operation.sequence > enabling.operation_floor && operation.state === 'completed'
|
||||||
&& Date.parse(host.lastHeartbeat ?? '') > Date.parse(operation.updated_at);
|
&& Date.parse(host.lastHeartbeat ?? '') > Date.parse(operation.updated_at);
|
||||||
const selections = queuedSelections(host.id);
|
const selections = queuedSelections(host.id);
|
||||||
const images = new Set([config.defaultRuntimeImage, ...selections.flatMap(selection => selection.image ? [selection.image] : [])]);
|
// Queued launches and migrations are not authority to pull custom images.
|
||||||
const result: Target[] = [...images].map(image => {
|
const result: Target[] = [config.defaultRuntimeImage].map(image => {
|
||||||
const operation = latestOperation(host.id, 'runtime.pull', 'image', image);
|
const operation = latestOperation(host.id, 'runtime.pull', 'image', image);
|
||||||
const runtimeReady = host.inventory.runtimeImages?.includes(image) && verifiedThisEnable(operation);
|
const runtimeReady = host.inventory.runtimeImages?.includes(image) && verifiedThisEnable(operation);
|
||||||
return { image, requiredBytes: host.minimumFreeBytes,
|
return { image, requiredBytes: host.minimumFreeBytes,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ensureHostSchema } from '../hosts/schema.js';
|
|||||||
import { unsettledCancelledCommand } from './cancellation.js';
|
import { unsettledCancelledCommand } from './cancellation.js';
|
||||||
import { config, relDestForKind, serverDir } from '../config.js';
|
import { config, relDestForKind, serverDir } from '../config.js';
|
||||||
import { nativeInstance, nativeRequest, unplacedNativeCreation, type NativeInstance } from '../backends/native.js';
|
import { nativeInstance, nativeRequest, unplacedNativeCreation, type NativeInstance } from '../backends/native.js';
|
||||||
import { getHost, getHostOperationStatus, queueHostOperation, queueHostOperationSerialized, releaseHostPorts, reserveHostPort } from '../hosts/index.js';
|
import { getHost, listHosts, getHostOperationStatus, queueHostOperation, queueHostOperationSerialized, releaseHostPorts, reserveHostPort } from '../hosts/index.js';
|
||||||
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
|
import { newestReadyLibraryId, assertReadyLibrary } from '../game-files/index.js';
|
||||||
import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
import { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||||
import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js';
|
import { prepareNativeLaunch, evaluateNativePlacement, type PreparedNativeLaunch } from './launch-preparation.js';
|
||||||
@@ -55,6 +55,14 @@ export function nativeLaunchSelection(input: Record<string, unknown>, game: stri
|
|||||||
if (!host) throw new Error('The selected host was not found.');
|
if (!host) throw new Error('The selected host was not found.');
|
||||||
if (host.decommission) throw new Error('The selected host is being removed. Choose another host.');
|
if (host.decommission) throw new Error('The selected host is being removed. Choose another host.');
|
||||||
}
|
}
|
||||||
|
// Only the controller-configured default may be prepared automatically.
|
||||||
|
// Custom runtimes must already be present in trusted host inventory; a
|
||||||
|
// launch request must never authorize a registry download.
|
||||||
|
if (image !== config.defaultRuntimeImage && !listHosts().some(host =>
|
||||||
|
!host.decommission && (automatic ? host.mode === 'active' : host.id === input.hostId)
|
||||||
|
&& host.inventory.runtimeImages?.includes(image))) {
|
||||||
|
throw new Error('Custom runtime images must be prepared by an admin from the host page before launching.');
|
||||||
|
}
|
||||||
const rawNetwork = input.automatic === true ? input.network ?? {} : input;
|
const rawNetwork = input.automatic === true ? input.network ?? {} : input;
|
||||||
if (!rawNetwork || typeof rawNetwork !== 'object' || Array.isArray(rawNetwork)) throw new Error('Invalid network overrides.');
|
if (!rawNetwork || typeof rawNetwork !== 'object' || Array.isArray(rawNetwork)) throw new Error('Invalid network overrides.');
|
||||||
const network = rawNetwork as NativeSelection;
|
const network = rawNetwork as NativeSelection;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}{/if}
|
{/each}{/if}
|
||||||
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must be prepared from the host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
|
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details>
|
||||||
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
|
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.5.0"
|
VERSION = "1.5.1"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1505,7 +1505,9 @@ class Agent:
|
|||||||
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
atomic_bytes(env_path, "".join(f"{key}={value}\n" for key, value in env.items()).encode())
|
||||||
name = self.container_name(instance_id, generation)
|
name = self.container_name(instance_id, generation)
|
||||||
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
spec_sha = digest(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode())
|
||||||
args = ["container", "create", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
# Do not let Docker fetch a replacement if the prepared image disappears
|
||||||
|
# between validation and container creation.
|
||||||
|
args = ["container", "create", "--pull=never", "--name", name, "--restart", "unless-stopped", "--label", LABEL + ".managed=true", "--label", LABEL + ".instance=" + instance_id, "--label", LABEL + ".generation=" + str(generation), "--label", LABEL + ".operation=" + (self.executing_id or "direct"), "--label", LABEL + ".spec-sha256=" + spec_sha, "--mount", f"type=bind,src={app},dst=/home/plutainer/app", "--mount", f"type=bind,src={library / 'content'},dst=/home/plutainer/gamefiles,readonly", "--env-file", str(env_path)]
|
||||||
if self.config.get("hostId"):
|
if self.config.get("hostId"):
|
||||||
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
args.extend(["--label", LABEL + ".host=" + identifier(self.config["hostId"])])
|
||||||
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
bind_arg = "[" + bind + "]" if ":" in bind else bind
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "ea0455b5439eb51ef86f9abbe5e7bb703ad5aa23397377e2f1e57a61fe56dcde",
|
"sha256": "67003980127327d07144041d0c17b8adaa0d466b30b0ebf7508a80efd5cf6481",
|
||||||
"updaterProtocolVersion": 1,
|
"updaterProtocolVersion": 1,
|
||||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,37 +337,35 @@ describe('automatic host preparation', () => {
|
|||||||
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
|
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 old = await publish();
|
||||||
const id = enroll({ runtimeImages: [runtime] });
|
|
||||||
const other = enroll({ runtimeImages: [runtime] });
|
|
||||||
const image = 'ghcr.io/example/plutainer:queued-version';
|
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');
|
const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
|
||||||
for (let index = 0; index < 2; index++) {
|
for (let index = 0; index < 2; index++) {
|
||||||
expect(queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', libraryId: old.id,
|
expect(queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', libraryId: old.id,
|
||||||
hostId: automatic ? undefined : id, image, plutoKey: 'saved-key' }).ok).toBe(true);
|
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 latest = await publish();
|
||||||
const status = preparation.hostPreparationStatus(hosts.getHost(id)!);
|
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);
|
expect(status.items.filter(item => item.label.includes('Queued version'))).toHaveLength(1);
|
||||||
const otherStatus = preparation.hostPreparationStatus(hosts.getHost(other)!);
|
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(otherStatus.items.some(item => item.label.includes('Queued version'))).toBe(automatic);
|
||||||
expect(operations(id)).toEqual([]);
|
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);
|
preparation.reconcileHostPreparation(id);
|
||||||
const download = operations(id)[0];
|
const download = operations(id)[0];
|
||||||
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: old.id, automatic: true } });
|
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: old.id, automatic: true } });
|
||||||
preparation.reconcileHostPreparation(id);
|
preparation.reconcileHostPreparation(id);
|
||||||
expect(hosts.getHostOperation(download.id)?.state).toBe('pending');
|
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);
|
verified(id, old);
|
||||||
vi.setSystemTime(Date.now() + 1);
|
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 () => {
|
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'));
|
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries'));
|
||||||
const image = 'plutainer:test';
|
const image = 'plutainer:test';
|
||||||
|
vi.stubEnv('PLUTAINER_IMAGE', image);
|
||||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||||
const offline = hosts.createHost({ name: 'Offline host' });
|
const offline = hosts.createHost({ name: 'Offline host' });
|
||||||
const ready = hosts.createHost({ name: 'Ready 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