cso-image-matrix.ts
161 lines · scripts/cso-image-matrix.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** Trusted CI input validation. Qualification evidence is handled separately. */
3import { readFileSync } from 'node:fs';
4import { resolve } from 'node:path';
6export const CSO_RUNTIME_PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
7export const CSO_RUNTIME_STACKS = ['node', 'bun', 'python', 'rails', 'postgresql'] as const;
8export type CsoRuntimeBuildPlatform = typeof CSO_RUNTIME_PLATFORMS[number];
9export type CsoRuntimeBuildStack = typeof CSO_RUNTIME_STACKS[number];
11const IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/;
12const TAGGED_IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13const RELEASE_TAG = /:v?\d+\.\d+\.\d+(?:[._-][A-Za-z0-9._-]*)?$/;
14const TWO_PART_RELEASE_TAG = /:v?\d+\.\d+(?:\.\d+)?(?:[._-][A-Za-z0-9._-]*)?$/;
15const REVISION = /^[a-z0-9][a-z0-9._-]{0,100}$/;
16const REQUIRED_VERSIONS: Record<CsoRuntimeBuildStack, string[]> = {
17 node: ['node', 'npm', 'cso-preparation'],
18 // oven/bun exposes a Bun-backed `node` fallback, not a real Node release.
19 // Recording a Node version here would make package engine admission unsound.
20 bun: ['bun', 'cso-preparation'],
21 python: ['python', 'uv', 'cso-preparation'],
22 rails: ['ruby', 'bundler', 'cso-preparation'],
23 postgresql: ['postgresql'],
24};
26export interface ImageBuildRow {
27 inputRevision: string;
28 profileId: string;
29 runtimeId: string;
30 stack: CsoRuntimeBuildStack;
31 platform: CsoRuntimeBuildPlatform;
32 arch: 'amd64' | 'arm64';
33 runner: 'ubuntu-24.04' | 'ubuntu-24.04-arm';
34 baseSource: string;
35 baseIndexImage: string;
36 baseImage: string;
37 uvSource: string;
38 uvIndexImage: string;
39 uvImage: string;
40 sbomGeneratorSource: string;
41 sbomGeneratorIndexImage: string;
42 sbomGeneratorImage: string;
43 versions: Record<string, string>;
44}
46function record(value: unknown): value is Record<string, unknown> {
47 return !!value && typeof value === 'object' && !Array.isArray(value);
48}
50function repositoryOfPinned(image: string): string {
51 return image.slice(0, image.lastIndexOf('@'));
52}
54function repositoryOfTag(image: string): string {
55 const slash = image.lastIndexOf('/');
56 const colon = image.lastIndexOf(':');
57 if (colon <= slash) throw new Error('INVALID_SOURCE_REFERENCE');
58 return image.slice(0, colon);
59}
61function assertPinnedFamily(source: unknown, indexImage: unknown, images: unknown, label: string, releaseTag = RELEASE_TAG): Record<CsoRuntimeBuildPlatform, string> {
62 if (typeof source !== 'string' || !TAGGED_IMAGE.test(source)) throw new Error(`INVALID_${label}_SOURCE`);
63 if (!releaseTag.test(source)) throw new Error(`FLOATING_${label}_SOURCE: ${source} must name an exact release version, not a moving major, minor or alias tag`);
64 if (typeof indexImage !== 'string' || !IMAGE.test(indexImage)) throw new Error(`UNPINNED_${label}_INDEX`);
65 if (!record(images)) throw new Error(`MISSING_${label}_IMAGES`);
66 const repository = repositoryOfTag(source);
67 if (repositoryOfPinned(indexImage) !== repository) throw new Error(`MISMATCHED_${label}_REPOSITORY`);
68 const pinned = {} as Record<CsoRuntimeBuildPlatform, string>;
69 for (const platform of CSO_RUNTIME_PLATFORMS) {
70 const image = images[platform];
71 if (typeof image !== 'string' || !IMAGE.test(image)) throw new Error(`UNPINNED_${label}_IMAGE: ${platform}`);
72 if (repositoryOfPinned(image) !== repository) throw new Error(`MISMATCHED_${label}_REPOSITORY`);
73 pinned[platform] = image;
74 }
75 if (pinned['linux/amd64'] === pinned['linux/arm64']) throw new Error(`DUPLICATE_${label}_PLATFORM_MANIFEST`);
76 return pinned;
77}
79/** Every declared stack must have both native platform manifests. */
80export function imageBuildMatrix(input: unknown): { include: ImageBuildRow[] } {
81 if (!record(input)) throw new Error('INVALID_BUILD_INPUTS');
82 if (input.schemaVersion !== 1 || input.helperAbi !== 3) throw new Error('INCOMPATIBLE_BUILD_INPUTS');
83 if (input.state !== 'reviewed') throw new Error('MISSING_REVIEWED_BUILD_INPUTS: review pinned images and exact tool versions before staging publication.');
84 if (typeof input.revision !== 'string' || !REVISION.test(input.revision)) throw new Error('INVALID_BUILD_INPUT_REVISION');
85 if (typeof input.reviewedAt !== 'string' || !Number.isFinite(Date.parse(input.reviewedAt))) throw new Error('MISSING_BUILD_INPUT_REVIEW');
86 if (typeof input.reviewMethod !== 'string' || input.reviewMethod.length < 40 || input.reviewMethod.length > 500) throw new Error('MISSING_BUILD_INPUT_REVIEW');
87 if (!record(input.sbomGenerator)) throw new Error('UNPINNED_SBOM_GENERATOR');
88 const sbomImages = assertPinnedFamily(
89 input.sbomGenerator.source,
90 input.sbomGenerator.indexImage,
91 input.sbomGenerator.images,
92 'SBOM_GENERATOR',
93 );
94 if (!Array.isArray(input.profiles) || input.profiles.length !== CSO_RUNTIME_STACKS.length) throw new Error('INCOMPLETE_STACK_MATRIX');
96 const rows: ImageBuildRow[] = [];
97 const seenStacks = new Set<string>();
98 const seenProfileIds = new Set<string>();
99 for (const raw of input.profiles) {
100 if (!record(raw)) throw new Error('INVALID_PROFILE');
101 const { id, stack, source, indexImage, baseImages, versions } = raw;
102 if (typeof id !== 'string' || !REVISION.test(id) || seenProfileIds.has(id)) throw new Error('INVALID_PROFILE_ID');
103 seenProfileIds.add(id);
104 if (typeof stack !== 'string' || !CSO_RUNTIME_STACKS.includes(stack as CsoRuntimeBuildStack) || seenStacks.has(stack)) throw new Error('INVALID_STACK');
105 seenStacks.add(stack);
106 const typedStack = stack as CsoRuntimeBuildStack;
107 const bases = assertPinnedFamily(source, indexImage, baseImages, 'BASE', typedStack === 'postgresql' ? TWO_PART_RELEASE_TAG : RELEASE_TAG);
108 if (!record(versions)) throw new Error('MISSING_TOOL_VERSIONS');
109 const exactVersion = (value: unknown) => typeof value === 'string' &&
110 (typedStack === 'postgresql'
111 ? /^\d+\.\d+(?:\.\d+)?(?:[.+_-][A-Za-z0-9.-]+)?$/
112 : /^\d+\.\d+\.\d+(?:[.+_-][A-Za-z0-9.-]+)?$/).test(value);
113 const expectedKeys = [...REQUIRED_VERSIONS[typedStack]].sort();
114 const suppliedKeys = Object.keys(versions).sort();
115 if (expectedKeys.join(',') !== suppliedKeys.join(',') || expectedKeys.some(key => !exactVersion(versions[key]))) throw new Error('UNPINNED_TOOL_VERSION');
116 if (typedStack !== 'postgresql' && versions['cso-preparation'] !== '1.0.0') throw new Error('INCOMPATIBLE_PREPARATION_HELPER');
118 let uvImages = {} as Record<CsoRuntimeBuildPlatform, string>;
119 if (typedStack === 'python') uvImages = assertPinnedFamily(raw.uvSource, raw.uvIndexImage, raw.uvImages, 'UV');
120 else if (raw.uvSource !== undefined || raw.uvIndexImage !== undefined || raw.uvImages !== undefined) throw new Error('UNEXPECTED_UV_IMAGE');
122 for (const platform of CSO_RUNTIME_PLATFORMS) {
123 const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';
124 rows.push({
125 inputRevision: input.revision,
126 profileId: id,
127 runtimeId: `${id}-${arch}`,
128 stack: typedStack,
129 platform,
130 arch,
131 runner: arch === 'amd64' ? 'ubuntu-24.04' : 'ubuntu-24.04-arm',
132 baseSource: source as string,
133 baseIndexImage: indexImage as string,
134 baseImage: bases[platform],
135 uvSource: typedStack === 'python' ? raw.uvSource as string : '',
136 uvIndexImage: typedStack === 'python' ? raw.uvIndexImage as string : '',
137 uvImage: typedStack === 'python' ? uvImages[platform] : '',
138 sbomGeneratorSource: input.sbomGenerator.source as string,
139 sbomGeneratorIndexImage: input.sbomGenerator.indexImage as string,
140 sbomGeneratorImage: sbomImages[platform],
141 versions: versions as Record<string, string>,
142 });
143 }
144 }
145 return { include: rows };
146}
148export function committedImageBuildMatrix(): { include: ImageBuildRow[] } {
149 const file = resolve(import.meta.dir, '../lib/cso/images/build-inputs.json');
150 return imageBuildMatrix(JSON.parse(readFileSync(file, 'utf8')));
151}
153if (import.meta.main) {
154 try {
155 if (process.argv.length !== 2) throw new Error('No arguments accepted; build inputs come from the reviewed repository file.');
156 process.stdout.write(JSON.stringify(committedImageBuildMatrix()) + '\n');
157 } catch (error) {
158 process.stderr.write((error instanceof Error ? error.message : 'INVALID_BUILD_INPUTS') + '\n');
159 process.exitCode = 1;
160 }
161}