import * as fs from 'node:fs'; import * as os from 'node:os'; import { join } from 'node:path'; import { CsoError } from './contracts'; import { dockerEndpoint, dockerExactImagePresent, dockerProbe, dockerPullExactCatalogImage } from './docker'; import { validateRuntimeCatalog, type RuntimeCatalog, type RuntimePlatform } from './runtime-catalog'; import { validateScannerCatalog, type ScannerCatalog } from './scanner-catalog'; import { secureDirectory } from './state'; export interface QualifiedCatalogImage { kind: 'runtime' | 'scanner'; id: string; image: string; platform: RuntimePlatform; } export interface CatalogImageSession { readonly docker: { endpoint: string; version: string; security: string[] }; present(entry: QualifiedCatalogImage, deadline?: number): Promise; pull(entry: QualifiedCatalogImage, deadline?: number): Promise; close(): void; } /** Doctor performs concurrent, read-only checks inside its 30-second contract. */ export const CATALOG_IMAGE_INSPECTION_BUDGET_MS = 30_000; export const DEFAULT_CATALOG_IMAGE_BUDGET_MS = 30_000; export const MIN_CATALOG_IMAGE_BUDGET_SECONDS = 5; export const MAX_CATALOG_IMAGE_BUDGET_SECONDS = 300; export const MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS = 60 * 60_000; const CATALOG_IMAGE_ADMISSION_BUDGET_MS = 30_000; export interface CatalogImageProvisioningPolicy { perImageMs: number; aggregateMs: number; } /** * Give every declared native-platform image a bounded opportunity to download. * The one-hour ceiling admits the current eleven-image catalog even at the * maximum configurable five-minute allowance. */ export function catalogImageProvisioningPolicy( imageCount: number, requestedSeconds?: string, ): CatalogImageProvisioningPolicy { if (!Number.isSafeInteger(imageCount) || imageCount < 0) throw new CsoError('INVALID_ARGUMENT', 'Catalog image count is invalid'); let seconds = DEFAULT_CATALOG_IMAGE_BUDGET_MS / 1000; if (requestedSeconds !== undefined) { if (!/^[0-9]+$/.test(requestedSeconds)) throw new CsoError('INVALID_ARGUMENT', '--per-image-seconds requires a whole number'); seconds = Number(requestedSeconds); if (seconds < MIN_CATALOG_IMAGE_BUDGET_SECONDS || seconds > MAX_CATALOG_IMAGE_BUDGET_SECONDS) throw new CsoError( 'INVALID_ARGUMENT', `--per-image-seconds must be ${MIN_CATALOG_IMAGE_BUDGET_SECONDS}..${MAX_CATALOG_IMAGE_BUDGET_SECONDS}`, ); } const perImageMs = seconds * 1000, aggregateMs = CATALOG_IMAGE_ADMISSION_BUDGET_MS + imageCount * perImageMs; if (!Number.isSafeInteger(aggregateMs) || aggregateMs > MAX_CATALOG_IMAGE_PROVISIONING_BUDGET_MS) throw new CsoError( 'INCOMPATIBLE_INPUT', 'Qualified image catalog exceeds the bounded setup preload capacity', ); return { perImageMs, aggregateMs }; } export type CatalogImageSessionFactory = (deadline: number) => Promise; export interface CatalogImageAvailability extends QualifiedCatalogImage { status: 'available' | 'unavailable'; reason?: string; } export interface CatalogImageInspection { docker: { status: 'ready' | 'missing'; detail: unknown }; images: CatalogImageAvailability[]; } export interface CatalogImageProvisionResult { schemaVersion: 1; status: 'complete' | 'partial' | 'not_available'; downloads: true; platform: RuntimePlatform; requested: number; inspected: number; alreadyPresent: number; downloaded: number; deadlineReached: boolean; unavailable: CatalogImageAvailability[]; summary: string; } export function qualifiedCatalogImages( runtimeCatalog: RuntimeCatalog, scannerCatalog: ScannerCatalog, platform: RuntimePlatform, ): QualifiedCatalogImage[] { validateRuntimeCatalog(runtimeCatalog); validateScannerCatalog(scannerCatalog); const entries: QualifiedCatalogImage[] = [ ...runtimeCatalog.runtimes .filter((item) => item.platform === platform) .map((item) => ({ kind: 'runtime' as const, id: item.id, image: item.image, platform: item.platform })), ...scannerCatalog.scanners .filter((item) => item.platform === platform) .map((item) => ({ kind: 'scanner' as const, id: item.id, image: item.image, platform: item.platform })), ]; const identities = new Set(); for (const entry of entries) { const identity = `${entry.kind}:${entry.id}`; if (identities.has(identity)) throw new CsoError('INCOMPATIBLE_INPUT', 'Qualified image catalogs contain a duplicate identity'); identities.add(identity); } return entries.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)); } function controlledReason(error: unknown, fallback: string): string { return error instanceof CsoError ? error.message : fallback; } export async function inspectCatalogImages( entries: QualifiedCatalogImage[], open: CatalogImageSessionFactory, deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS, ): Promise { let session: CatalogImageSession; try { session = await open(deadline); } catch (error) { const detail = controlledReason(error, 'Local Docker is unavailable for exact catalog image inspection'); return { docker: { status: 'missing', detail }, images: entries.map((entry) => ({ ...entry, status: 'unavailable', reason: detail })), }; } try { // Read-only daemon lookups run together so doctor remains within its // 30-second contract even when a local Docker client is slow to fail. const images = await Promise.all( entries.map(async (entry): Promise => { try { const present = await session.present(entry); if (Date.now() >= deadline) throw new CsoError( 'DEADLINE', 'Exact image inspection reached the aggregate image-provisioning deadline', ); return { ...entry, status: present ? 'available' : 'unavailable', ...(present ? {} : { reason: 'Exact qualified image is not present in the local Docker daemon' }), }; } catch (error) { return { ...entry, status: 'unavailable', reason: controlledReason(error, 'Exact qualified image could not be inspected safely'), }; } }), ); return { docker: { status: 'ready', detail: session.docker }, images }; } finally { session.close(); } } export async function provisionCatalogImages( entries: QualifiedCatalogImage[], platform: RuntimePlatform, open: CatalogImageSessionFactory, deadline = Date.now() + catalogImageProvisioningPolicy(entries.length).aggregateMs, perImageBudgetMs = DEFAULT_CATALOG_IMAGE_BUDGET_MS, ): Promise { if (!entries.length) return { schemaVersion: 1, status: 'complete', downloads: true, platform, requested: 0, inspected: 0, alreadyPresent: 0, downloaded: 0, deadlineReached: false, unavailable: [], summary: 'No qualified CSO images are published for this platform; static audits remain available.', }; if ( !Number.isSafeInteger(perImageBudgetMs) || perImageBudgetMs < 1 || perImageBudgetMs > MAX_CATALOG_IMAGE_BUDGET_SECONDS * 1000 ) throw new CsoError('INVALID_ARGUMENT', 'Catalog per-image budget is invalid'); const deadlineReason = 'The bounded aggregate CSO image preload deadline was reached'; if (Date.now() >= deadline) { const unavailable = entries.map((entry) => ({ ...entry, status: 'unavailable' as const, reason: deadlineReason, })); return { schemaVersion: 1, status: 'partial', downloads: true, platform, requested: entries.length, inspected: 0, alreadyPresent: 0, downloaded: 0, deadlineReached: true, unavailable, summary: `Qualified CSO image preload partial: 0/${entries.length} available; ${deadlineReason.toLowerCase()}. Rerun setup to continue.`, }; } let session: CatalogImageSession; try { session = await open(deadline); } catch (error) { const reason = controlledReason(error, 'Local Docker is unavailable for qualified image provisioning'), unavailable = entries.map((entry) => ({ ...entry, status: 'unavailable' as const, reason })); const deadlineReached = error instanceof CsoError && error.code === 'DEADLINE'; return { schemaVersion: 1, status: deadlineReached ? 'partial' : 'not_available', downloads: true, platform, requested: entries.length, inspected: 0, alreadyPresent: 0, downloaded: 0, deadlineReached, unavailable, summary: deadlineReached ? `Qualified CSO image preload partial: 0/${entries.length} available; ${reason}. Rerun setup to continue.` : `Qualified CSO images were not preloaded: ${reason}. Rerun setup after the prerequisite is available.`, }; } let inspected = 0, alreadyPresent = 0, downloaded = 0, pullBlocked = '', deadlineReached = false, perImageTimeouts = 0; const unavailable: CatalogImageAvailability[] = []; try { for (let index = 0; index < entries.length; index++) { const entry = entries[index]; if (Date.now() >= deadline) { deadlineReached = true; for (const remaining of entries.slice(index)) unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); break; } const imageDeadline = Math.min(deadline, Date.now() + perImageBudgetMs), perImageReason = `The ${Math.ceil(perImageBudgetMs / 1000)}-second per-image CSO preload deadline was reached`; let present = false; try { present = await session.present(entry, imageDeadline); if (Date.now() >= imageDeadline) throw new CsoError( 'DEADLINE', imageDeadline === deadline ? 'Exact image inspection reached the aggregate image-provisioning deadline' : perImageReason, ); inspected++; if (present) { alreadyPresent++; continue; } } catch (error) { if (error instanceof CsoError && error.code === 'DEADLINE') { if (Date.now() >= deadline) { deadlineReached = true; unavailable.push({ ...entry, status: 'unavailable', reason: error.message }); for (const remaining of entries.slice(index + 1)) unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); break; } perImageTimeouts++; unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason }); continue; } unavailable.push({ ...entry, status: 'unavailable', reason: controlledReason(error, 'Exact qualified image could not be inspected safely'), }); continue; } // A registry failure blocks further network attempts, but read-only local // inspection continues so the setup summary never calls a cached digest // unavailable merely because it sorts after the failed pull. if (pullBlocked) { unavailable.push({ ...entry, status: 'unavailable', reason: `Network provisioning stopped after an anonymous registry prerequisite failed: ${pullBlocked}`, }); continue; } if (Date.now() >= deadline) { deadlineReached = true; unavailable.push({ ...entry, status: 'unavailable', reason: deadlineReason }); for (const remaining of entries.slice(index + 1)) unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); break; } try { await session.pull(entry, imageDeadline); if (Date.now() >= imageDeadline) throw new CsoError( 'DEADLINE', imageDeadline === deadline ? 'Qualified image pull reached the aggregate preload deadline' : perImageReason, ); downloaded++; } catch (error) { if (error instanceof CsoError && error.code === 'DEADLINE') { if (Date.now() >= deadline) { deadlineReached = true; unavailable.push({ ...entry, status: 'unavailable', reason: error.message }); for (const remaining of entries.slice(index + 1)) unavailable.push({ ...remaining, status: 'unavailable', reason: deadlineReason }); break; } perImageTimeouts++; unavailable.push({ ...entry, status: 'unavailable', reason: perImageReason }); continue; } pullBlocked = controlledReason(error, 'Qualified image provisioning failed'); unavailable.push({ ...entry, status: 'unavailable', reason: pullBlocked }); } } } finally { session.close(); } const status = deadlineReached ? 'partial' : unavailable.length ? alreadyPresent || downloaded ? 'partial' : 'not_available' : 'complete'; const summary = deadlineReached ? `Qualified CSO image preload partial: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; the bounded aggregate deadline was reached. Rerun setup to continue.` : perImageTimeouts ? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${perImageTimeouts} exceeded the ${Math.ceil(perImageBudgetMs / 1000)}-second per-image deadline. Increase GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS within 5..300 or rerun setup to continue.` : unavailable.length ? `Qualified CSO image preload ${status}: ${alreadyPresent + downloaded}/${entries.length} available; inspected ${inspected}/${entries.length}; ${unavailable.length} require local Docker and anonymous public registry access. Rerun setup after the prerequisite is available.` : `Qualified CSO images ready: ${entries.length} available (${downloaded} downloaded, ${alreadyPresent} already local).`; return { schemaVersion: 1, status, downloads: true, platform, requested: entries.length, inspected, alreadyPresent, downloaded, deadlineReached, unavailable, summary, }; } export async function openLocalCatalogImageSession( env: Record = process.env, deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS, ): Promise { let home = ''; try { home = secureDirectory(fs.mkdtempSync(join(fs.realpathSync(os.tmpdir()), 'gstack-cso-images-'))); // Endpoint discovery and the daemon probe must not borrow the download // allowance. A slow or hostile local Docker endpoint gets the same bounded // admission window in doctor and setup; successful pulls keep the caller's // larger aggregate deadline below. const admissionDeadline = Math.min(deadline, Date.now() + CATALOG_IMAGE_ADMISSION_BUDGET_MS); const endpoint = await dockerEndpoint(home, env, admissionDeadline), config = secureDirectory(join(home, 'docker-config')); // dockerEnvironment pins both HOME and DOCKER_CONFIG here. An explicit // empty auth map prevents inherited credential stores/helpers from being // consulted during installation-time public pulls. fs.writeFileSync(join(config, 'config.json'), '{"auths":{}}\n', { encoding: 'utf8', mode: 0o600, flag: 'wx', }); const probe = await dockerProbe(endpoint, home, admissionDeadline), docker = { endpoint: endpoint.uri, ...probe }; let closed = false; return { docker, present: (entry, operationDeadline = deadline) => { if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed'); return dockerExactImagePresent( endpoint, home, entry.image, entry.platform, Math.min(deadline, operationDeadline), ); }, pull: (entry, operationDeadline = deadline) => { if (closed) throw new CsoError('ISOLATION_FAILED', 'Catalog image session is closed'); return dockerPullExactCatalogImage( endpoint, home, entry.image, entry.platform, Math.min(deadline, operationDeadline), ); }, close: () => { if (closed) return; closed = true; fs.rmSync(home, { recursive: true, force: true }); }, }; } catch (error) { if (home) fs.rmSync(home, { recursive: true, force: true }); throw error; } }