54 lines
5.0 KiB
JavaScript
54 lines
5.0 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { refuse } from './lane-state.mjs';
|
|
// This is the declared build input, not an observation of a running binary.
|
|
function pinnedBase(checkout){
|
|
const lines=readFileSync(join(checkout,'Containerfile'),'utf8').split('\n').filter(line=>/^\s*FROM(?:\s|$)/i.test(line));
|
|
if(lines.length!==1)refuse('image requires exactly one pinned FROM');
|
|
const fields=lines[0].trim().split(/\s+/);
|
|
if(fields.length!==2||!/^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[a-f0-9]{64}$/.test(fields[1]))refuse('image requires one literal digest-pinned FROM');
|
|
return fields[1];
|
|
}
|
|
export function imageFingerprint(checkout){
|
|
const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8');
|
|
const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim());
|
|
const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/','COPY adapters /opt/mosaic/adapters'];
|
|
if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required');
|
|
const hash=createHash('sha256');
|
|
const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');};
|
|
for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters','packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'])visit(rel);
|
|
return hash.digest('hex');
|
|
}
|
|
export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){
|
|
return JSON.parse(execFileSync(docker,['image','inspect',tag,'--format','{{json .}}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}));
|
|
}
|
|
export function verifyImage(receipt,checkout,inspect=inspectImage){
|
|
if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid');
|
|
if(receipt.baseDigest!==pinnedBase(checkout))refuse('image base differs from pinned Containerfile');
|
|
if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ');
|
|
const actual=inspect(receipt.tag);
|
|
if(actual.Id!==receipt.imageId)refuse('pinned image ID differs');
|
|
if(`${actual.Os}/${actual.Architecture}`!==receipt.platform)refuse('image platform differs');
|
|
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
|
if(pkg.dependencies?.['@earendil-works/pi-coding-agent']&&receipt.tag!==`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`)refuse('image tag differs from release identity');
|
|
return receipt;
|
|
}
|
|
// Called by the CLI only while both lanes and the image lock are exclusive.
|
|
export function prepareImage(checkout,{grant,tempRoot,out},invoke){
|
|
if(!grant)refuse('missing image preparation grant');
|
|
const baseDigest=pinnedBase(checkout);
|
|
const inputDigest=imageFingerprint(checkout);
|
|
const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
|
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();
|
|
if(!/^\d+\.\d+\.\d+$/.test(release))refuse('invalid image release');
|
|
const tag=`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`;
|
|
const run=invoke??((args)=>execFileSync('docker',args,{cwd:checkout,encoding:'utf8',stdio:['ignore','pipe','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,...(process.env.DOCKER_HOST?{DOCKER_HOST:process.env.DOCKER_HOST}:{}),COMPOSE_PROJECT_NAME:'mg197-image-prepare',MOSAIC_IMAGE_TAG:tag,MOSAIC_DATA_ROOT:join(tempRoot,'data'),MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',PI_AUTH_FILE:'/dev/null'}}));
|
|
const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start;
|
|
if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building');
|
|
const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}']));
|
|
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest,platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
|
|
verifyImage(receipt,checkout,()=>actual);return receipt;
|
|
}
|