Files
gstack/lib/cso/image-provisioning.ts
T
garrytan d93d61f7ba style(cso): format lib/cso TypeScript with pinned Prettier
Mechanical reformat only. Minified transpile output is byte-identical for
21 of 22 files; witness.ts differs only in three regex flag orders
(/mi -> /im), which JavaScript canonicalizes. Source-text assertions over
lib/cso now compare whitespace-insensitively with the same tokens.
2026-09-29 14:22:48 +00:00

421 lines
16 KiB
TypeScript

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<boolean>;
pull(entry: QualifiedCatalogImage, deadline?: number): Promise<void>;
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<CatalogImageSession>;
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<string>();
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<CatalogImageInspection> {
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<CatalogImageAvailability> => {
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<CatalogImageProvisionResult> {
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<string, string | undefined> = process.env,
deadline = Date.now() + CATALOG_IMAGE_INSPECTION_BUDGET_MS,
): Promise<CatalogImageSession> {
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;
}
}