Restrict launches to prepared runtime images

This commit is contained in:
2026-10-05 23:26:18 -04:00
parent 3952c4c938
commit f8ac2436b5
11 changed files with 161 additions and 29 deletions
+5 -6
View File
@@ -10,22 +10,21 @@ const activeStates = ['pending', 'accepted', 'running', 'unknown'];
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 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 };
/** Accepted requests retain their prerequisites until placement owns an instance. */
function queuedSelections(hostId: string): QueuedSelection[] {
// 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 [];
return db().prepare(`SELECT DISTINCT json_extract(q.selection,'$.libraryId') AS libraryId,
json_extract(q.selection,'$.image') AS image FROM native_creation_intents q
return db().prepare(`SELECT DISTINCT json_extract(q.selection,'$.libraryId') AS libraryId FROM native_creation_intents q
JOIN server_deployments d ON d.id=q.deployment_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
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
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
WHERE m.removed_at IS NULL AND d.server_deleted_at IS NULL
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'
&& Date.parse(host.lastHeartbeat ?? '') > Date.parse(operation.updated_at);
const selections = queuedSelections(host.id);
const images = new Set([config.defaultRuntimeImage, ...selections.flatMap(selection => selection.image ? [selection.image] : [])]);
const result: Target[] = [...images].map(image => {
// Queued launches and migrations are not authority to pull custom images.
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);
return { image, requiredBytes: host.minimumFreeBytes,
+9 -1
View File
@@ -9,7 +9,7 @@ import { ensureHostSchema } from '../hosts/schema.js';
import { unsettledCancelledCommand } from './cancellation.js';
import { config, relDestForKind, serverDir } from '../config.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 { blueprintRecipe, captureRecipePlan, recipeForDeployment, recipeStorage, summarizeRecipe, type LaunchRecipePlan } from '../fleet/launch-recipes.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.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;
if (!rawNetwork || typeof rawNetwork !== 'object' || Array.isArray(rawNetwork)) throw new Error('Invalid network overrides.');
const network = rawNetwork as NativeSelection;
@@ -57,7 +57,7 @@
</div>
{/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>
<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}
</section>