157 lines
13 KiB
JavaScript
157 lines
13 KiB
JavaScript
import test from 'node:test';import assert from 'node:assert/strict';
|
|
import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync,symlinkSync,unlinkSync } from 'node:fs';import { join, isAbsolute } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
|
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
|
|
|
function fixtureBase(root){return /^FROM (\S+)$/m.exec(readFileSync(join(root,'Containerfile'),'utf8'))[1];}
|
|
const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'];
|
|
function copyProcessingInputs(root){
|
|
mkdirSync(join(root,'packages/processing-control/src'),{recursive:true});
|
|
for(const file of processingInputs)cpSync(new URL('../../../'+file,import.meta.url),join(root,file));
|
|
}
|
|
|
|
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
|
|
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
|
|
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
|
copyProcessingInputs(root);
|
|
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
|
|
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
|
|
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:fixtureBase(root),platform:'linux/amd64'};
|
|
const inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
|
assert.equal(image.verifyImage(receipt,root,inspect).imageId,id);
|
|
assert.throws(()=>image.verifyImage(receipt,root,()=>({...inspect(),Id:'sha256:'+'c'.repeat(64)})),/image ID/);
|
|
writeFileSync(join(root,'adapters','one'),'different');assert.throws(()=>image.verifyImage(receipt,root,inspect),/inputs/);
|
|
});
|
|
test('warm Docker guard disables implicit build and pull and refuses image mutation',t=>{
|
|
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
|
|
const run=composeFixture(t);
|
|
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run,run.env),['compose','-f',join(run.checkout,'compose.yaml'),'-f',join(run.tempRoot,'compose-no-build.yaml'),'run','--pull','never','--no-deps','--rm','-T','mosaic-agent','prompt']);
|
|
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
|
|
});
|
|
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
|
|
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
|
|
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
|
copyProcessingInputs(root);
|
|
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
|
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify({Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
|
|
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
|
|
const result=image.prepareImage(root,{grant:'fixture',tempRoot:root},invoke);assert.equal(result.imageId,'sha256:'+'a'.repeat(64));assert.equal(result.builds,1);assert.ok(result.buildWallMs>=0);assert.ok(calls.some(x=>x.join(' ')==='compose build'));
|
|
});
|
|
test('cleanup refuses a foreign Docker label and removes only an exact owned project',()=>{
|
|
const project='mg197-a-abcdef123456',id='123456789abc';let removed=false,label='foreign';
|
|
const fake=(args)=>{if(args[0]==='ps')return removed?'':id;if(args[0]==='network')return '';if(args[0]==='container'&&args[1]==='inspect')return label;if(args[0]==='container'&&args[1]==='rm'){removed=true;return '';}throw Error('unexpected command');};
|
|
// The injectable executor exercises ownership checks without touching Docker.
|
|
assert.throws(()=>docker.cleanupProject({project},fake),/foreign/);assert.equal(removed,false);
|
|
label=project;assert.deepEqual(docker.cleanupProject({project},fake).removed,[{kind:'container',id}]);assert.equal(removed,true);
|
|
});
|
|
function imageFixture(t){
|
|
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
|
copyProcessingInputs(root);
|
|
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
|
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:fixtureBase(root),platform:'linux/amd64'};
|
|
return {root,receipt};
|
|
}
|
|
test('missing receipt and missing image refuse without permitting a gate',t=>{
|
|
const {root,receipt}=imageFixture(t);assert.throws(()=>image.verifyImage(null,root),/provenance receipt/);
|
|
assert.throws(()=>image.verifyImage(receipt,root,()=>{throw Error('No such image');}),/No such image/);
|
|
assert.throws(()=>image.verifyImage({...receipt,tag:'mosaic-poc-agent:wrong'},root,()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'})),/release identity/);
|
|
});
|
|
test('a tag changed between supervised steps fails the next boundary and suppresses gate success',async t=>{
|
|
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);
|
|
let actual=receipt.imageId;const inspect=()=>({Id:actual,Os:'linux',Architecture:'amd64'}),events=[];
|
|
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
|
await assert.rejects(executePinnedSteps([
|
|
{name:'first',cwd:root,argv:[process.execPath,'-e','process.exit(0)']},
|
|
{name:'second',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}
|
|
],{out,env:process.env,record:r=>events.push(r)}, {receipt,checkout:root,inspect,afterStep:()=>actual='sha256:'+'c'.repeat(64)}),/image ID/);
|
|
assert.equal(events.filter(e=>e.kind==='step').length,1);
|
|
assert.equal(events.some(e=>e.kind==='terminal'&&e.rc===0),false);
|
|
});
|
|
test('pinned-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
|
|
const {root}=imageFixture(t);let builds=0,id='sha256:'+'a'.repeat(64);
|
|
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify({Id:id,Os:'linux',Architecture:'amd64'});};
|
|
const cold=image.prepareImage(root,{grant:'fixture-cold',tempRoot:root},fake);
|
|
assert.equal(cold.builds,1);assert.ok(cold.buildWallMs>=0);assert.equal(builds,1);
|
|
for(let i=0;i<3;i++)image.verifyImage(cold,root,()=>({Id:id,Os:'linux',Architecture:'amd64'}));
|
|
assert.equal(builds,1,'warm verification builds zero images');
|
|
id='sha256:'+'c'.repeat(64);const rebuilt=image.prepareImage(root,{grant:'fixture-rebuild',tempRoot:root},fake);
|
|
assert.equal(rebuilt.inputDigest,cold.inputDigest);assert.equal(rebuilt.builds,1);assert.equal(builds,2);
|
|
assert.throws(()=>image.verifyImage(cold,root,()=>({Id:rebuilt.imageId,Os:'linux',Architecture:'amd64'})),/image ID/);
|
|
});
|
|
test('warm pinned execution records zero builds and checks the tag again after its final step',async t=>{
|
|
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);let id=receipt.imageId;
|
|
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
|
const options={out,env:process.env,record:()=>{}},inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
|
const result=await executePinnedSteps([{name:'warm',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect});
|
|
assert.equal(result.rc,0);assert.equal(result.imageBuilds,0);
|
|
await assert.rejects(executePinnedSteps([{name:'last',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect,afterStep:()=>id='sha256:'+'c'.repeat(64)}),/image ID/);
|
|
});
|
|
|
|
|
|
// These two read-only compatibility probes must reach the installed binary,
|
|
// including when the lane's guard is first on PATH. Child fixture env stays
|
|
// isolated; passing the real absolute path avoids re-entering that guard.
|
|
function installedDocker(){
|
|
const file=process.env.MOSAIC_GATE_DOCKER_RECORD;
|
|
if(!file)return 'docker';
|
|
const binary=JSON.parse(readFileSync(file,'utf8')).docker;
|
|
assert.ok(typeof binary==='string'&&isAbsolute(binary),'lane record must name the real absolute Docker binary');
|
|
return binary;
|
|
}
|
|
function composeFixture(t) {
|
|
const tempRoot=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(tempRoot,{recursive:true,force:true}));
|
|
const checkout=join(tempRoot,'candidate');mkdirSync(checkout);cpSync(new URL('../../../compose.yaml',import.meta.url),join(checkout,'compose.yaml'));
|
|
const config=join(tempRoot,'docker-config');mkdirSync(config,{mode:0o700});
|
|
const auth=join(tempRoot,'fixture-auth.json');writeFileSync(auth,'{}',{mode:0o600});
|
|
return {tempRoot,checkout,project:'mg197-a-abcdef123456',tag:'mosaic-poc-agent:fixture',imageId:'sha256:'+'a'.repeat(64),
|
|
env:{PATH:process.env.PATH,HOME:process.env.HOME,DOCKER_CONFIG:config,COMPOSE_PROJECT_NAME:'mg197-a-abcdef123456',
|
|
MOSAIC_IMAGE_TAG:'mosaic-poc-agent:fixture',MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',MOSAIC_DATA_ROOT:tempRoot,PI_AUTH_FILE:auth}};
|
|
}
|
|
test('installed Compose accepts rewritten run argv without starting a container',t=>{
|
|
const run=composeFixture(t);
|
|
if(docker.writeComposeOverride)docker.writeComposeOverride(run);
|
|
const argv=docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','fixture'],run,run.env);
|
|
const command=argv.indexOf('run');assert.ok(command>0);
|
|
// --help is before SERVICE, so parsing exits before any service operation.
|
|
const parsed=spawnSync(installedDocker(),[...argv.slice(0,command+1),'--help',...argv.slice(command+1)],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
|
assert.equal(parsed.status,0,parsed.stdout+parsed.stderr);
|
|
assert.match(parsed.stdout,/Usage:/);
|
|
});
|
|
test('installed Compose resolves the private override with no build and the pinned image',t=>{
|
|
const run=composeFixture(t);assert.equal(typeof docker.writeComposeOverride,'function');
|
|
const override=docker.writeComposeOverride(run);
|
|
assert.equal(override,join(run.tempRoot,'compose-no-build.yaml'));
|
|
assert.equal(statSync(override).mode&0o777,0o600);
|
|
assert.equal(docker.writeComposeOverride(run),override,'parallel callers reuse identical private content');
|
|
const argv=docker.guardDocker(['compose','run','--rm','mosaic-agent'],run,run.env),command=argv.indexOf('run');
|
|
const resolved=spawnSync(installedDocker(),[...argv.slice(0,command),'config','--format','json'],{cwd:run.checkout,env:run.env,encoding:'utf8',timeout:10000});
|
|
assert.equal(resolved.status,0,resolved.stdout+resolved.stderr);
|
|
const service=JSON.parse(resolved.stdout).services['mosaic-agent'];
|
|
assert.equal(Object.hasOwn(service,'build'),false);assert.equal(service.image,run.imageId);
|
|
t.diagnostic(JSON.stringify({installedComposeConfig:{buildPresent:Object.hasOwn(service,'build'),image:service.image}}));
|
|
assert.ok(argv.includes('--no-deps'));assert.equal(argv[argv.indexOf('--pull')+1],'never');
|
|
writeFileSync(override,'changed fixture');assert.throws(()=>docker.writeComposeOverride(run),/override/);
|
|
unlinkSync(override);const foreign=join(run.tempRoot,'foreign');writeFileSync(foreign,'fixture');symlinkSync(foreign,override);
|
|
assert.throws(()=>docker.writeComposeOverride(run),/override/);assert.equal(readFileSync(foreign,'utf8'),'fixture');
|
|
});
|
|
test('guard owns every Compose file argument and refuses caller configuration overrides',t=>{
|
|
const run=composeFixture(t);
|
|
for(const args of [['-f','foreign.yaml'],['--file','foreign.yaml'],['--file=foreign.yaml'],['--project-directory','/tmp'],['--build'],['--pull','always'],['--no-build']])
|
|
assert.throws(()=>docker.guardDocker(['compose','run',...args,'mosaic-agent'],run),/options not allowed/);
|
|
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
|
|
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
|
|
});
|
|
|
|
// Row 191 image-input manifest: either copied reader must invalidate the pinned image.
|
|
for(const file of processingInputs)test(`changing ${file} invalidates the image receipt`,t=>{
|
|
const {root,receipt}=imageFixture(t);
|
|
const inspect=()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'});
|
|
assert.equal(image.verifyImage(receipt,root,inspect).imageId,receipt.imageId);
|
|
writeFileSync(join(root,file),readFileSync(join(root,file),'utf8')+'\n// changed fixture input\n');
|
|
assert.throws(()=>image.verifyImage(receipt,root,inspect),/image inputs differ/);
|
|
});
|