Skill Tracer
Skills › gstack › gstack › cso-runtime-promotion.ts

cso-runtime-promotion.ts

392 lines · scripts/cso-runtime-promotion.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** Generate a reviewable catalog candidate from authenticated qualification statements. */
3import * as fs from 'node:fs';
4import * as path from 'node:path';
5import { canonical, sha256 as sha256Hex } from '../lib/cso/contracts';
6import committedCatalog from '../lib/cso/runtime-catalog.json';
7import buildInputs from '../lib/cso/images/build-inputs.json';
8import {
9 CSO_HELPER_ABI,
10 EVALUATION_REVISION_PREFIX,
11 isEvaluationRuntimeCatalog,
12 validRequalificationTriggers,
13 type RequalificationTriggers,
14 type QualifiedRuntime,
15 type RuntimeCatalog,
16 type RuntimeQualification,
17 validateRuntimeCatalog,
18} from '../lib/cso/runtime-catalog';
19import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
20import { imageBuildMatrix, type ImageBuildRow } from './cso-image-matrix';
21import { committedPreparation, requalificationTriggers, staleRequalificationTriggers } from './cso-requalification';
23const DIGEST = /^sha256:[a-f0-9]{64}$/;
24const WORKFLOW = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]+$/;
25const OUTPUT_IMAGE = /^ghcr\.io\/garrytan\/gstack\/cso-staging\/(node|bun|python|rails|postgresql)-(amd64|arm64)@sha256:[a-f0-9]{64}$/;
26const MAX_EVIDENCE_FILES = 10;
27const MAX_EVIDENCE_BYTES = 1024 * 1024;
29export interface RuntimeQualificationStatement {
30 schemaVersion: 1;
31 helperAbi: 3;
32 state: 'qualified';
33 buildRevision: string;
34 runtimeId: string;
35 stack: ImageBuildRow['stack'];
36 platform: ImageBuildRow['platform'];
37 image: string;
38 versions: Record<string, string>;
39 sourceCommit: string;
40 workflow: string;
41 qualifiedAt: string;
42 sbomDigest: string;
43 provenanceDigest: string;
44 checks: Record<string, true>;
47function sha256(value: string | Buffer): string {
48 return `sha256:${sha256Hex(value)}`;
51function versionsKey(value: Record<string, string>): string {
52 return canonical(value);
55export function requiredChecks(stack: ImageBuildRow['stack']): string[] {
56 if (stack === 'postgresql') return [
57 'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed',
58 'secretCanaryPassed', 'watchdogCleanupPassed',
59 ].sort();
60 const common = [
61 'accuracyGatesPassed', 'acquisitionPublicOnlyPassed', 'coldStartPassed', 'containmentPassed',
62 'heldOutRepairPassed', 'offlineLifecyclePassed', 'positiveNegativeAssertionsPassed',
63 'secretCanaryPassed', 'watchdogCleanupPassed',
64 ];
65 return stack === 'rails'
66 ? [...common, 'nativeExtensionsPassed', 'railsPostgresqlPassed', 'railsSqlitePassed'].sort()
67 : common.sort();
70function qualification(statement: RuntimeQualificationStatement): RuntimeQualification {
71 if (statement.stack === 'postgresql') return {
72 kind: 'postgresql',
73 sourceCommit: statement.sourceCommit,
74 workflow: statement.workflow,
75 sbomDigest: statement.sbomDigest,
76 provenanceDigest: statement.provenanceDigest,
77 verifiedProvenance: true,
78 containmentPassed: true,
79 coldStartPassed: true,
80 multiDatabasePassed: true,
81 readinessPassed: true,
82 };
83 return {
84 kind: 'application',
85 sourceCommit: statement.sourceCommit,
86 workflow: statement.workflow,
87 sbomDigest: statement.sbomDigest,
88 provenanceDigest: statement.provenanceDigest,
89 verifiedProvenance: true,
90 containmentPassed: true,
91 coldStartPassed: true,
92 positiveNegativeAssertionsPassed: true,
93 heldOutRepairPassed: true,
94 };
97function validateStatement(raw: unknown, row: ImageBuildRow): RuntimeQualificationStatement {
98 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INVALID_QUALIFICATION_STATEMENT');
99 const statement = raw as RuntimeQualificationStatement;
100 const expectedFields = [
101 'buildRevision', 'checks', 'helperAbi', 'image', 'platform', 'provenanceDigest',
102 'qualifiedAt', 'runtimeId', 'sbomDigest', 'schemaVersion', 'sourceCommit', 'stack',
103 'state', 'versions', 'workflow',
104 ];
105 if (canonical(Object.keys(statement).sort()) !== canonical(expectedFields.sort())) throw new Error('INVALID_QUALIFICATION_STATEMENT_FIELDS');
106 if (statement.schemaVersion !== 1 || statement.helperAbi !== CSO_HELPER_ABI || statement.state !== 'qualified') throw new Error('UNQUALIFIED_RUNTIME_EVIDENCE');
107 if (statement.buildRevision !== row.inputRevision || statement.runtimeId !== row.runtimeId ||
108 statement.stack !== row.stack || statement.platform !== row.platform ||
109 versionsKey(statement.versions) !== versionsKey(row.versions)) throw new Error(`QUALIFICATION_BUILD_MISMATCH: ${row.runtimeId}`);
110 if (!OUTPUT_IMAGE.test(statement.image) || !statement.image.includes(`/${row.stack}-${row.arch}@`)) throw new Error(`INVALID_QUALIFIED_IMAGE: ${row.runtimeId}`);
111 if (!/^[a-f0-9]{40}$/.test(statement.sourceCommit) || !WORKFLOW.test(statement.workflow) ||
112 !Number.isFinite(Date.parse(statement.qualifiedAt)) || !DIGEST.test(statement.sbomDigest) ||
113 !DIGEST.test(statement.provenanceDigest)) throw new Error(`MISSING_QUALIFICATION_PROVENANCE: ${row.runtimeId}`);
114 if (!statement.checks || typeof statement.checks !== 'object' || Array.isArray(statement.checks)) throw new Error(`MISSING_RELEASE_GATES: ${row.runtimeId}`);
115 const expected = requiredChecks(row.stack);
116 const actual = Object.keys(statement.checks).sort();
117 if (canonical(actual) !== canonical(expected) || actual.some(key => statement.checks[key] !== true)) throw new Error(`MISSING_RELEASE_GATES: ${row.runtimeId}`);
118 return statement;
121/** The committed (never evaluation-only) catalog and the reviewed build matrix it must describe. */
122function reviewedMatrix(currentValue: unknown, inputValue: unknown): { current: RuntimeCatalog; rows: ImageBuildRow[] } {
123 validateRuntimeCatalog(currentValue);
124 const current = currentValue as RuntimeCatalog;
125 if (isEvaluationRuntimeCatalog(current)) throw new Error('EVALUATION_CATALOG_NOT_PROMOTABLE');
126 return { current, rows: profilesMatchingRows(current, inputValue) };
129function profilesMatchingRows(current: RuntimeCatalog, inputValue: unknown): ImageBuildRow[] {
130 const rows = imageBuildMatrix(inputValue).include;
131 if (!current.profiles || current.buildRevision !== rows[0].inputRevision) throw new Error('CATALOG_BUILD_REVISION_MISMATCH');
132 const profiles = new Map(current.profiles.map(profile => [profile.id, profile]));
133 if (profiles.size !== rows.length) throw new Error('CATALOG_PROFILE_MATRIX_MISMATCH');
134 for (const row of rows) {
135 const profile = profiles.get(row.runtimeId);
136 if (!profile || profile.stack !== row.stack || profile.platform !== row.platform ||
137 versionsKey(profile.versions) !== versionsKey(row.versions)) throw new Error(`CATALOG_PROFILE_MATRIX_MISMATCH: ${row.runtimeId}`);
138 }
139 return rows;
142/** The candidate build's gate: a valid evaluation-only catalog for the reviewed build inputs. */
143export function validateEvaluationCandidate(catalogValue: unknown, inputValue: unknown): RuntimeCatalog {
144 validateRuntimeCatalog(catalogValue);
145 const catalog = catalogValue as RuntimeCatalog;
146 if (!isEvaluationRuntimeCatalog(catalog)) throw new Error('NOT_AN_EVALUATION_CATALOG');
147 const rows = profilesMatchingRows(catalog, inputValue);
148 for (const row of rows) {
149 const runtime = catalog.runtimes.find(item => item.id === row.runtimeId);
150 if (!runtime || !OUTPUT_IMAGE.test(runtime.image) || !runtime.image.includes(`/${row.stack}-${row.arch}@`)) throw new Error(`INVALID_QUALIFIED_IMAGE: ${row.runtimeId}`);
151 }
152 return catalog;
155function matchRows(rows: ImageBuildRow[], values: unknown[]): Array<[ImageBuildRow, unknown]> {
156 if (values.length !== rows.length) throw new Error('INCOMPLETE_QUALIFICATION_MATRIX');
157 const byId = new Map<string, unknown>();
158 for (const value of values) {
159 const id = (value as any)?.runtimeId;
160 if (typeof id !== 'string' || byId.has(id)) throw new Error('DUPLICATE_QUALIFICATION_STATEMENT');
161 byId.set(id, value);
162 }
163 return rows.map(row => {
164 if (!byId.has(row.runtimeId)) throw new Error(`MISSING_QUALIFICATION_STATEMENT: ${row.runtimeId}`);
165 return [row, byId.get(row.runtimeId)];
166 });
169const STAGED_FIELDS = [
170 'buildRevision', 'helperAbi', 'image', 'platform', 'provenanceDigest', 'qualification', 'runtimeId',
171 'sbomDigest', 'schemaVersion', 'sourceCommit', 'stack', 'state', 'verifiedProvenance', 'versions', 'workflow',
172].sort();
174/**
175 * Build an evaluation-only catalog from one staging run's staged-image.json
176 * records. It measures no release gate: every runtime is an
177 * evaluation_candidate, and setup, distribution builds and promotion refuse it.
178 */
179export function evaluationCandidateCatalog(currentValue: unknown, inputValue: unknown, stagedValues: unknown[], createdAt: string): RuntimeCatalog {
180 const { current, rows } = reviewedMatrix(currentValue, inputValue);
181 if (!Number.isFinite(Date.parse(createdAt))) throw new Error('INVALID_EVALUATION_TIME');
182 const staged = matchRows(rows, stagedValues).map(([row, value]) => {
183 const record = value as RuntimeQualificationStatement;
184 if (!record || typeof record !== 'object' || canonical(Object.keys(record).sort()) !== canonical(STAGED_FIELDS) ||
185 record.schemaVersion !== 1 || record.helperAbi !== CSO_HELPER_ABI || (record.state as string) !== 'staged' || (record as any).verifiedProvenance !== true) throw new Error(`INVALID_STAGED_RUNTIME: ${row.runtimeId}`);
186 if (record.buildRevision !== row.inputRevision || record.stack !== row.stack || record.platform !== row.platform ||
187 versionsKey(record.versions) !== versionsKey(row.versions)) throw new Error(`QUALIFICATION_BUILD_MISMATCH: ${row.runtimeId}`);
188 if (!OUTPUT_IMAGE.test(record.image) || !record.image.includes(`/${row.stack}-${row.arch}@`)) throw new Error(`INVALID_QUALIFIED_IMAGE: ${row.runtimeId}`);
189 if (!/^[a-f0-9]{40}$/.test(record.sourceCommit) || !WORKFLOW.test(record.workflow) || !DIGEST.test(record.sbomDigest) ||
190 !DIGEST.test(record.provenanceDigest)) throw new Error(`MISSING_QUALIFICATION_PROVENANCE: ${row.runtimeId}`);
191 return record;
192 });
193 if (new Set(staged.map(item => item.sourceCommit)).size !== 1 || new Set(staged.map(item => item.workflow)).size !== 1) throw new Error('SPLIT_STAGING_RUN');
194 const { sourceCommit, workflow } = staged[0];
195 const runtimes: QualifiedRuntime[] = staged.map(record => ({
196 id: record.runtimeId,
197 stack: record.stack,
198 platform: record.platform,
199 state: 'evaluation_candidate',
200 image: record.image,
201 entrypoint: '/opt/cso/entrypoint',
202 helperAbi: CSO_HELPER_ABI,
203 versions: record.versions,
204 policyVersion: 'cso-isolation-v1',
205 qualifiedAt: createdAt,
206 qualification: { kind: 'evaluation', sourceCommit, workflow, sbomDigest: record.sbomDigest, provenanceDigest: record.provenanceDigest, verifiedProvenance: true },
207 }));
208 const candidate: RuntimeCatalog = {
209 schemaVersion: 1,
210 revision: `${EVALUATION_REVISION_PREFIX}${workflow.slice(workflow.lastIndexOf('/') + 1)}-${sourceCommit.slice(0, 12)}`,
211 previousRevision: current.revision,
212 helperAbi: CSO_HELPER_ABI,
213 buildRevision: current.buildRevision,
214 profiles: current.profiles,
215 evaluation: { sourceCommit, workflow, evidenceDigest: sha256(canonical(runtimes)) },
216 runtimes,
217 };
218 validateRuntimeCatalog(candidate);
219 return candidate;
222export function catalogPromotionCandidate(
223 currentValue: unknown,
224 inputValue: unknown,
225 evidenceValues: unknown[],
226 /** Measured by the staging run at sourceCommit (scripts/cso-requalification.ts triggers). */
227 requalification: RequalificationTriggers,
228): RuntimeCatalog {
229 const { current, rows } = reviewedMatrix(currentValue, inputValue);
230 if (!validRequalificationTriggers(requalification, current.buildRevision)) throw new Error('INVALID_REQUALIFICATION_TRIGGERS');
231 if (requalification.isolationPolicyHash !== ISOLATION_POLICY_HASH) throw new Error('STALE_RUNTIME_QUALIFICATION: isolationPolicyHash');
232 const statements = matchRows(rows, evidenceValues).map(([row, value]) => validateStatement(value, row));
233 const sourceCommits = new Set(statements.map(item => item.sourceCommit));
234 const workflows = new Set(statements.map(item => item.workflow));
235 if (sourceCommits.size !== 1 || workflows.size !== 1) throw new Error('SPLIT_QUALIFICATION_RUN');
236 const sourceCommit = statements[0].sourceCommit;
237 const workflow = statements[0].workflow;
238 const runId = workflow.slice(workflow.lastIndexOf('/') + 1);
239 const runtimes: QualifiedRuntime[] = statements.map(statement => ({
240 id: statement.runtimeId,
241 stack: statement.stack,
242 platform: statement.platform,
243 state: 'qualified',
244 image: statement.image,
245 entrypoint: '/opt/cso/entrypoint',
246 helperAbi: CSO_HELPER_ABI,
247 versions: statement.versions,
248 policyVersion: 'cso-isolation-v1',
249 qualifiedAt: statement.qualifiedAt,
250 qualification: qualification(statement),
251 }));
252 const qualificationEvidenceDigest = sha256(canonical(statements));
253 const evidenceDigest = sha256(canonical(runtimes));
254 const candidate: RuntimeCatalog = {
255 schemaVersion: 1,
256 revision: `cso-v3-${runId}-${sourceCommit.slice(0, 12)}`,
257 previousRevision: current.revision,
258 helperAbi: CSO_HELPER_ABI,
259 buildRevision: current.buildRevision,
260 profiles: current.profiles,
261 promotion: {
262 sourceCommit, workflow, evidenceDigest, qualificationEvidenceDigest,
263 requalification: {
264 helperAbi: requalification.helperAbi,
265 isolationPolicyHash: requalification.isolationPolicyHash,
266 preparationSha256: requalification.preparationSha256,
267 buildInputsRevision: requalification.buildInputsRevision,
268 },
269 },
270 runtimes,
271 };
272 validateRuntimeCatalog(candidate);
273 return candidate;
276/**
277 * The release contract: the promotion candidate, refused when any trigger moved since the staged commit.
278 * A recorded preparationSha256 that differs from the current one is re-measured at the staged commit in
279 * `source`, so a staging record made under the legacy rule compares under the current rule.
280 */
281export async function releaseCandidate(
282 evidenceValues: unknown[],
283 recorded: RequalificationTriggers,
284 current: RequalificationTriggers,
285 source?: string,
286): Promise<RuntimeCatalog> {
287 let candidate = catalogPromotionCandidate(committedCatalog, buildInputs, evidenceValues, recorded);
288 if (recorded.preparationSha256 !== current.preparationSha256) {
289 const staged = await committedPreparation(candidate.promotion!.sourceCommit, source);
290 if (recorded.preparationSha256 !== staged.current && recorded.preparationSha256 !== staged.legacy) {
291 throw new Error('REQUALIFICATION_TRIGGERS_MISMATCH: preparationSha256 was not measured at the staged commit');
292 }
293 candidate = catalogPromotionCandidate(committedCatalog, buildInputs, evidenceValues, { ...recorded, preparationSha256: staged.current });
294 }
295 const stale = staleRequalificationTriggers(candidate, current);
296 if (stale.length) throw new Error(`STALE_RUNTIME_QUALIFICATION: ${stale.join(', ')} changed since the staged commit; re-stage and requalify`);
297 return candidate;
300/** A promotion may only replace the exact catalog revision it was generated from. */
301export function validateRuntimeCatalogTransition(currentValue: unknown, proposedValue: unknown): void {
302 validateRuntimeCatalog(currentValue);
303 validateRuntimeCatalog(proposedValue);
304 const current = currentValue as RuntimeCatalog;
305 const proposed = proposedValue as RuntimeCatalog;
306 if (isEvaluationRuntimeCatalog(current) || isEvaluationRuntimeCatalog(proposed)) throw new Error('EVALUATION_CATALOG_NOT_PROMOTABLE');
307 if (!proposed.promotion || proposed.runtimes.length === 0) throw new Error('UNQUALIFIED_RUNTIME_CATALOG_PROMOTION');
308 if (proposed.revision === current.revision || proposed.previousRevision !== current.revision) {
309 throw new Error('RUNTIME_CATALOG_BASE_REVISION_MISMATCH');
310 }
311 if (proposed.buildRevision !== current.buildRevision ||
312 canonical(proposed.profiles) !== canonical(current.profiles)) {
313 throw new Error('RUNTIME_CATALOG_PROFILE_TRANSITION_MISMATCH');
314 }
317function collectEvidence(root: string, name = 'qualified-runtime.json'): unknown[] {
318 const absolute = path.resolve(root);
319 const rootStat = fs.lstatSync(absolute);
320 if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('UNSAFE_EVIDENCE_ROOT');
321 const files: string[] = [];
322 const visit = (directory: string, depth: number): void => {
323 if (depth > 4) throw new Error('EVIDENCE_TREE_TOO_DEEP');
324 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
325 const file = path.join(directory, entry.name);
326 if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error('UNSAFE_EVIDENCE_ENTRY');
327 if (entry.isDirectory()) visit(file, depth + 1);
328 else if (entry.name === name) files.push(file);
329 if (files.length > MAX_EVIDENCE_FILES) throw new Error('TOO_MANY_QUALIFICATION_STATEMENTS');
330 }
331 };
332 visit(absolute, 0);
333 return files.sort().map(file => {
334 const before = fs.lstatSync(file);
335 if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > MAX_EVIDENCE_BYTES) throw new Error('UNSAFE_QUALIFICATION_STATEMENT');
336 const body = fs.readFileSync(file, 'utf8');
337 const after = fs.lstatSync(file);
338 if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size ||
339 before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error('QUALIFICATION_EVIDENCE_RACE');
340 try { return JSON.parse(body); } catch { throw new Error('INVALID_QUALIFICATION_STATEMENT'); }
341 });
344function readCatalogArtifact(file: string): unknown {
345 const absolute = path.resolve(file);
346 const before = fs.lstatSync(absolute);
347 if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size <= 0 || before.size > MAX_EVIDENCE_BYTES) {
348 throw new Error('UNSAFE_RUNTIME_CATALOG_ARTIFACT');
349 }
350 const body = fs.readFileSync(absolute, 'utf8');
351 const after = fs.lstatSync(absolute);
352 if (before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode ||
353 before.nlink !== after.nlink || before.size !== after.size || before.mtimeMs !== after.mtimeMs ||
354 before.ctimeMs !== after.ctimeMs) throw new Error('RUNTIME_CATALOG_ARTIFACT_RACE');
355 try { return JSON.parse(body); } catch { throw new Error('INVALID_RUNTIME_CATALOG_ARTIFACT'); }
358if (import.meta.main) {
359 try {
360 const args = process.argv.slice(2);
361 if (args[0] === 'check-evaluation') {
362 if (args.length !== 2) throw new Error('Usage: cso-runtime-promotion.ts check-evaluation <cso-eval-catalog>');
363 const catalog = validateEvaluationCandidate(readCatalogArtifact(args[1]), buildInputs);
364 process.stdout.write(`EVALUATION-ONLY ${catalog.revision}\n`);
365 } else if (args[0] === 'evaluation-candidate') {
366 if (![5, 7].includes(args.length) || args[1] !== '--staged-root' || args[3] !== '--output' || (args.length === 7 && args[5] !== '--created-at')) {
367 throw new Error('Usage: cso-runtime-promotion.ts evaluation-candidate --staged-root <directory> --output <new-file> [--created-at <ISO time>]');
368 }
369 const candidate = evaluationCandidateCatalog(committedCatalog, buildInputs, collectEvidence(args[2], 'staged-image.json'), args[6] ?? new Date().toISOString());
370 const output = path.resolve(args[4]);
371 fs.writeFileSync(output, JSON.stringify(candidate, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
372 process.stdout.write(`${output}\n`);
373 } else if (args[0] === 'validate-transition') {
374 if (args.length !== 3) throw new Error('Usage: cso-runtime-promotion.ts validate-transition <current-catalog> <proposed-catalog>');
375 validateRuntimeCatalogTransition(readCatalogArtifact(args[1]), readCatalogArtifact(args[2]));
376 process.stdout.write('VALID TRANSITION\n');
377 } else {
378 if (args.length !== 4 || args[0] !== '--evidence-root' || args[2] !== '--output') {
379 throw new Error('Usage: cso-runtime-promotion.ts --evidence-root <directory> --output <new-file>');
380 }
381 const triggers = collectEvidence(args[1], 'requalification.json');
382 if (triggers.length !== 1) throw new Error('MISSING_REQUALIFICATION_TRIGGERS');
383 const candidate = await releaseCandidate(collectEvidence(args[1]), triggers[0] as RequalificationTriggers, await requalificationTriggers());
384 const output = path.resolve(args[3]);
385 fs.writeFileSync(output, JSON.stringify(candidate, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
386 process.stdout.write(`${output}\n`);
387 }
388 } catch (error) {
389 process.stderr.write((error instanceof Error ? error.message : 'RUNTIME_PROMOTION_FAILED') + '\n');
390 process.exitCode = 1;
391 }