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(secondsMAX_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=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;} }