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
+3
View File
@@ -156,7 +156,10 @@ export interface HostInventory {
dockerVersion?: string;
dockerRunning?: boolean;
directoriesWritable?: boolean;
/** Agent-reported Docker cache; removed from presented host inventory. */
runtimeImages?: string[];
/** Prepared, locally available image references eligible for launches. */
preparedRuntimeImages?: string[];
libraries?: HostLibrary[];
currentOperation?: string | null;
[key: string]: unknown;
+1 -1
View File
@@ -198,7 +198,7 @@ export function launchAvailability(selections: NativePlacement[]): {
);
const eligible = (candidate: HostAvailability, selection: NativePlacement) =>
(!selection.hostId || selection.automatic || candidate.host.id === selection.hostId) &&
!!candidate.host.inventory.runtimeImages?.includes(selection.image ?? '') &&
!!candidate.host.inventory.preparedRuntimeImages?.includes(selection.image ?? '') &&
!!candidate.host.inventory.libraries?.some(
(library) =>
library.id === selection.libraryId && ['ready', 'outdated'].includes(library.state)
+1 -1
View File
@@ -66,7 +66,7 @@ export async function readCapacityCurrent(query: CapacityQuery): Promise<Capacit
if (!host.credentialActive && host.enrolled)
reasons.push('Host credentials are inactive.');
if (host.readiness !== 'ready') reasons.push(...host.readinessReasons);
if (!host.inventory.runtimeImages?.includes(config.defaultRuntimeImage))
if (!host.inventory.preparedRuntimeImages?.includes(config.defaultRuntimeImage))
reasons.push('The default runtime is not ready.');
if (!requestedGames.some((game) => games.includes(game)))
reasons.push('Latest game files for this selection are not ready.');
+11 -9
View File
@@ -140,7 +140,7 @@ function hostFromRow(row: HostRow): Host {
)
reasons.push('Insufficient available game storage.');
const infrastructureReady = reasons.length === 0;
const runtimeReady = !!inventory.runtimeImages?.length;
const runtimeReady = !!inventory.preparedRuntimeImages?.length;
const libraryReady = !!inventory.libraries?.some((library) => library.state === 'ready');
if (!runtimeReady) reasons.push('The Plutainer runtime image is not ready yet.');
if (!libraryReady) reasons.push('No verified game-file library is available yet.');
@@ -676,12 +676,14 @@ export function validateInventory(value: unknown): HostInventory {
for (const key of ['dockerRunning', 'directoriesWritable'])
if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean')
throw new Error(`Invalid inventory ${key}.`);
if (
inventory.runtimeImages !== undefined &&
(!Array.isArray(inventory.runtimeImages) ||
inventory.runtimeImages.some((image) => typeof image !== 'string'))
)
throw new Error('Invalid runtime image inventory.');
for (const key of ['runtimeImages', 'preparedRuntimeImages'] as const) {
const images = inventory[key];
if (
images !== undefined &&
(!Array.isArray(images) || images.some((image) => typeof image !== 'string'))
)
throw new Error('Invalid runtime image inventory.');
}
if (
inventory.capabilities !== undefined &&
(!Array.isArray(inventory.capabilities) ||
@@ -952,8 +954,8 @@ export function requireReadyHost(
)
)
reasons.push('The selected game library is not ready on this host.');
if (options.image && !host.inventory.runtimeImages?.includes(options.image))
reasons.push('The selected runtime image is not cached on this host.');
if (options.image && !host.inventory.preparedRuntimeImages?.includes(options.image))
reasons.push('The selected runtime image is not prepared on this host.');
if (
options.port !== undefined &&
!hostPortAvailable(hostId, options.port, options.bindAddress, options.protocol)
+1 -1
View File
@@ -190,7 +190,7 @@ function targets(host: Host): Target[] {
const result: Target[] = [config.defaultRuntimeImage].map((image) => {
const operation = latestOperation(host.id, 'runtime.pull', 'image', image);
const runtimeReady =
host.inventory.runtimeImages?.includes(image) && verifiedThisEnable(operation);
host.inventory.preparedRuntimeImages?.includes(image) && verifiedThisEnable(operation);
return {
image,
requiredBytes: host.minimumFreeBytes,
+15 -1
View File
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { config } from '../config.js';
import type { HostInventory } from '../../hosts.js';
type InventoryCache = Map<string, { json: string; inventory: HostInventory }>;
@@ -21,7 +22,20 @@ export function parseHostInventory(hostId: string, json: string): HostInventory
const cache = requests.getStore()?.inventories;
const saved = cache?.get(hostId);
if (saved?.json === json) return saved.inventory;
const inventory: HostInventory = JSON.parse(json);
// Full Docker inventory can include unrelated workloads. Keep it out of
// every presented host, including inventory persisted by older agents.
const { runtimeImages, ...inventory }: HostInventory = JSON.parse(json);
// The configured default is explicitly trusted by the controller. Older
// agents may prove it is cached, but cannot authorize any custom runtime.
const reported = inventory.preparedRuntimeImages;
const prepared = new Set(
Array.isArray(reported) && reported.every((image) => typeof image === 'string')
? reported
: []
);
if (runtimeImages?.includes(config.defaultRuntimeImage))
prepared.add(config.defaultRuntimeImage);
inventory.preparedRuntimeImages = [...prepared];
// Compare the stored JSON so heartbeats and transaction rollbacks within
// a request remain visible. Other host state is always read afresh.
cache?.set(hostId, { json, inventory });
+2 -2
View File
@@ -136,7 +136,7 @@ export function nativeLaunchSelection(
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
// Custom runtimes must be reported as admin-prepared by the host agent; a
// launch request must never authorize a registry download.
if (
image !== config.defaultRuntimeImage &&
@@ -144,7 +144,7 @@ export function nativeLaunchSelection(
(host) =>
!host.decommission &&
(automatic ? host.mode === 'active' : host.id === input.hostId) &&
host.inventory.runtimeImages?.includes(image)
host.inventory.preparedRuntimeImages?.includes(image)
)
) {
throw new Error(
@@ -45,7 +45,7 @@
hosts.filter(
(host) =>
host.readiness === 'ready' &&
host.inventory.runtimeImages?.includes(selection.image)
host.inventory.preparedRuntimeImages?.includes(selection.image)
)
);
const latest = $derived(latestGameFiles(libraries));