11import { execFileSync , spawnSync } from 'node:child_process' ;
2+ import { removeRunnerObjects } from './fixtures/docker-sweep.ts' ;
23import { fixtureGit } from './fixtures/git.ts' ;
34import { createHash , randomBytes , randomUUID } from 'node:crypto' ;
45import { chmodSync , existsSync , mkdtempSync , mkdirSync , readdirSync , readFileSync , realpathSync , renameSync , rmSync , statSync ,
@@ -39,7 +40,8 @@ vi.mock('node:fs', async importOriginal => {
3940 return actual . realpathSync ( path ) ;
4041 } ) as typeof actual . realpathSync } ;
4142} ) ;
42- const TEST_RUNNER_OWNER = '0123456789abcdef0123456789abcdef' ;
43+ // Each run has its own runner owner, so counting the storage it owns ignores what a concurrent or failed run holds.
44+ const TEST_RUNNER_OWNER = randomBytes ( 16 ) . toString ( 'hex' ) ;
4345const testOwner = ( attemptId = 'fixture' ) => ( { runnerOwner : TEST_RUNNER_OWNER , attemptId, allocationId : randomUUID ( ) } ) ;
4446
4547const roots : string [ ] = [ ] ;
@@ -52,9 +54,16 @@ const git = fixtureGit;
5254const docker = ( ...args : string [ ] ) => execFileSync ( 'docker' , args , {
5355 encoding : 'utf8' , timeout : 60_000 , stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
5456} ) . trim ( ) ;
57+ // Every volume, network and container of one allocation, found by that label alone: another run's objects never
58+ // count, and neither a missing nor a wrong runner label can hide one.
59+ const byAllocation = ( allocationId : string ) => [ 'volume' , 'network' ] . flatMap ( kind => docker ( kind , 'ls' , '--quiet' ,
60+ '--filter' , `label=io.codeboost.allocation=${ allocationId } ` ) . split ( '\n' ) . filter ( Boolean ) )
61+ . concat ( docker ( 'ps' , '--all' , '--quiet' , '--filter' , `label=io.codeboost.allocation=${ allocationId } ` ) . split ( '\n' )
62+ . filter ( Boolean ) ) ;
5563
5664function fixture ( options : { limits ?: Parameters < typeof prepareTaskFilesystems > [ 1 ] ; historyBytes ?: number ;
57- hostile ?: ( source : string , root : string ) => void ; beforeSeed ?: ( clone : string ) => void ; objectFormat ?: 'sha256' } = { } ) {
65+ hostile ?: ( source : string , root : string ) => void ; beforeSeed ?: ( clone : string ) => void ; objectFormat ?: 'sha256' ;
66+ owner ?: ReturnType < typeof testOwner > } = { } ) {
5867 const root = mkdtempSync ( join ( tmpdir ( ) , 'agent-container-' ) ) ; roots . push ( root ) ;
5968 const source = join ( root , 'source' ) , staging = join ( root , 'staging' ) , input = join ( root , 'input' ) ;
6069 mkdirSync ( source ) ; mkdirSync ( staging ) ; mkdirSync ( input ) ;
@@ -75,7 +84,7 @@ function fixture(options: { limits?: Parameters<typeof prepareTaskFilesystems>[1
7584 options . beforeSeed ?.( clone . directory ) ;
7685 const filesystems = prepareTaskFilesystems ( clone , options . limits ?? {
7786 workBytes : 16 * 1024 * 1024 , workInodes : 512 , metadataBytes : 16 * 1024 * 1024 , metadataInodes : 512 ,
78- } , imageId , testOwner ( ) ) ;
87+ } , imageId , options . owner ?? testOwner ( ) ) ;
7988 taskFilesystems . push ( filesystems ) ;
8089 const fakeAuth = join ( root , 'auth.json' ) ; writeFileSync ( fakeAuth , '{}' , { mode : 0o600 } ) ;
8190 return { root, source, input, clone, filesystems, fakeAuth } ;
@@ -124,13 +133,20 @@ afterEach(async () => {
124133 for ( const network of vendorNetworks . splice ( 0 ) . reverse ( ) ) await removeVendorNetwork ( network ) ;
125134} , 120_000 ) ;
126135afterAll ( async ( ) => {
127- for ( const container of containers ) spawnSync ( 'docker' , [ 'rm' , '--force' , container ] , { stdio : 'ignore' } ) ;
128- for ( const filesystems of taskFilesystems . reverse ( ) ) removeTaskFilesystems ( filesystems ) ;
129- for ( const profile of profiles ) await disposeContainerProfile ( profile ) ;
130- for ( const network of vendorNetworks . splice ( 0 ) . reverse ( ) ) await removeVendorNetwork ( network ) ;
131- for ( const root of roots . reverse ( ) ) {
132- chmodSync ( join ( root , 'input' ) , 0o700 ) ;
133- rmSync ( root , { recursive : true , force : true , maxRetries : 3 , retryDelay : 50 } ) ;
136+ try {
137+ for ( const container of containers ) spawnSync ( 'docker' , [ 'rm' , '--force' , container ] , { stdio : 'ignore' } ) ;
138+ for ( const filesystems of taskFilesystems . reverse ( ) ) removeTaskFilesystems ( filesystems ) ;
139+ for ( const profile of profiles ) await disposeContainerProfile ( profile ) ;
140+ for ( const network of vendorNetworks . splice ( 0 ) . reverse ( ) ) await removeVendorNetwork ( network ) ;
141+ for ( const root of roots . reverse ( ) ) {
142+ chmodSync ( join ( root , 'input' ) , 0o700 ) ;
143+ rmSync ( root , { recursive : true , force : true , maxRetries : 3 , retryDelay : 50 } ) ;
144+ }
145+ } finally {
146+ // The runner owner is this run's own, so nothing else would find what this run leaves behind. Objects a test
147+ // makes under another owner or without the runner label are not found here; those tests remove their own. It may
148+ // not run before a hook timeout.
149+ removeRunnerObjects ( TEST_RUNNER_OWNER ) ;
134150 }
135151} , 120_000 ) ;
136152
@@ -282,14 +298,12 @@ describe('real Docker agent isolation', () => {
282298 symlinkSync ( target , join ( source , 'L' ) ) ;
283299 } ] as const ) ,
284300 ] as const ) ( 'refuses to seed a repository with %s, and leaves no storage behind' , async ( _label , hostile ) => {
285- const owned = ( ) => [ docker ( 'volume' , 'ls' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ,
286- docker ( 'ps' , '--all' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ] . join ( '\n' ) . split ( '\n' ) . filter ( Boolean ) ;
287- const before = new Set ( owned ( ) ) ;
288- const refused = ( ( ) => { try { fixture ( { hostile } ) ; } catch ( error ) { return error ; } } ) ( ) ;
301+ const owner = testOwner ( ) ;
302+ const refused = ( ( ) => { try { fixture ( { hostile, owner } ) ; } catch ( error ) { return error ; } } ) ( ) ;
289303 // Its own error type, so a caller can tell an unusable repository from a Docker failure, with the reason first.
290304 expect ( refused ) . toBeInstanceOf ( UnusableRepositoryError ) ;
291305 expect ( ( refused as Error ) . message ) . toMatch ( / ^ R e p o s i t o r y l i n k l e a v e s t h e c h e c k o u t : / ) ;
292- expect ( owned ( ) . filter ( id => ! before . has ( id ) ) ) . toEqual ( [ ] ) ;
306+ expect ( byAllocation ( owner . allocationId ) ) . toEqual ( [ ] ) ;
293307 } , 60_000 ) ;
294308
295309 // Names are bytes: a link whose name is not UTF-8, beside a file named what a decoder turns it into. Linux only: a
@@ -307,16 +321,14 @@ describe('real Docker agent isolation', () => {
307321 head : git ( data . source , 'rev-parse' , 'HEAD' ) } ) ;
308322 // /work/.git is mounted read-only, which stops writes but not reads through a link.
309323 symlinkSync ( '/run/codeboost-auth/codex/auth.json' , join ( clone . directory , '.git' , 'credential' ) ) ;
310- const owned = ( ) => [ docker ( 'volume' , 'ls' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ,
311- docker ( 'ps' , '--all' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ] . join ( '\n' ) . split ( '\n' ) . filter ( Boolean ) ;
312- const before = new Set ( owned ( ) ) ;
324+ const owner = testOwner ( ) ;
313325 const refused = ( ( ) => {
314326 try { prepareTaskFilesystems ( clone , { workBytes : 16 * 1024 * 1024 , workInodes : 512 , metadataBytes : 16 * 1024 * 1024 ,
315- metadataInodes : 512 } , imageId , testOwner ( ) ) ; } catch ( error ) { return error ; }
327+ metadataInodes : 512 } , imageId , owner ) ; } catch ( error ) { return error ; }
316328 } ) ( ) ;
317329 expect ( refused ) . toBeInstanceOf ( UnusableRepositoryError ) ;
318330 expect ( ( refused as Error ) . message ) . toMatch ( / ^ R e p o s i t o r y G i t m e t a d a t a c o n t a i n s a l i n k : / ) ;
319- expect ( owned ( ) . filter ( id => ! before . has ( id ) ) ) . toEqual ( [ ] ) ;
331+ expect ( byAllocation ( owner . allocationId ) ) . toEqual ( [ ] ) ;
320332 } , 60_000 ) ;
321333
322334 it ( 'seeds links that stay inside the checkout, including loops and not-yet-existing targets' , async ( ) => {
@@ -336,13 +348,11 @@ describe('real Docker agent isolation', () => {
336348 } , 60_000 ) ;
337349
338350 it ( 'fails closed without leaving storage when a repository exceeds its allocation' , async ( ) => {
339- const owned = ( ) => [ docker ( 'volume' , 'ls' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ,
340- docker ( 'ps' , '--all' , '--quiet' , '--filter' , 'label=io.codeboost.allocation' ) ] . join ( '\n' ) . split ( '\n' ) . filter ( Boolean ) ;
341- const before = new Set ( owned ( ) ) ;
342- expect ( ( ) => fixture ( { historyBytes : 4 * 1024 * 1024 , limits : {
351+ const owner = testOwner ( ) ;
352+ expect ( ( ) => fixture ( { owner, historyBytes : 4 * 1024 * 1024 , limits : {
343353 workBytes : 16 * 1024 * 1024 , workInodes : 512 , metadataBytes : 1024 * 1024 , metadataInodes : 512 ,
344354 } } ) ) . toThrow ( ) ;
345- expect ( owned ( ) . filter ( id => ! before . has ( id ) ) ) . toEqual ( [ ] ) ;
355+ expect ( byAllocation ( owner . allocationId ) ) . toEqual ( [ ] ) ;
346356 } , 60_000 ) ;
347357
348358 it ( 'persists execution changes while replacing HOME and scratch for each invocation' , async ( ) => {
@@ -634,13 +644,13 @@ describe('real Docker agent isolation', () => {
634644 } ;
635645
636646 it ( 'rejects a vendor network or proxy without its runner label, and removes them' , async ( ) => {
637- const data = fixture ( ) , egress = ( ) => docker ( ' network' , 'ls' , '--quiet' , '--filter' , ' label=io.codeboost.egress' ) ;
638- const before = egress ( ) ;
647+ // Counted by allocation, not by runner: first the network, then the proxy, is made without the runner label.
648+ const data = fixture ( ) , allocations = [ randomUUID ( ) , randomUUID ( ) ] ;
639649 await withoutRunnerLabel ( [ [ 'network' , 'create' ] ] , ( ) => expect ( createVendorNetwork ( invocation ( data . clone , 'planning' ) ,
640- imageId , randomUUID ( ) ) ) . rejects . toThrow ( 'changed after allocation' ) ) ;
650+ imageId , allocations [ 0 ] ! ) ) . rejects . toThrow ( 'changed after allocation' ) ) ;
641651 await withoutRunnerLabel ( [ [ 'create' , '--name' ] ] , ( ) => expect ( createVendorNetwork ( invocation ( data . clone , 'planning' ) ,
642- imageId , randomUUID ( ) ) ) . rejects . toThrow ( 'changed after allocation' ) ) ;
643- expect ( egress ( ) ) . toBe ( before ) ;
652+ imageId , allocations [ 1 ] ! ) ) . rejects . toThrow ( 'changed after allocation' ) ) ;
653+ expect ( allocations . flatMap ( byAllocation ) ) . toEqual ( [ ] ) ;
644654 } , 120_000 ) ;
645655
646656 // Storage has no captured IDs, so cleanup refuses an object without every owner label; the test removes its own.
@@ -707,10 +717,6 @@ describe('real Docker agent isolation', () => {
707717 "const allocation = args.find(arg => arg.startsWith('io.codeboost.allocation='));" ,
708718 `run(['volume', 'create', '--label', 'io.codeboost.runner=${ 'f' . repeat ( 32 ) } ', '--label', allocation, '${ name } ']);` ,
709719 ] . join ( '\n' ) ;
710- const byAllocation = ( allocationId : string ) => [ 'volume' , 'network' ] . flatMap ( kind => docker ( kind , 'ls' , '--quiet' ,
711- '--filter' , `label=io.codeboost.allocation=${ allocationId } ` ) . split ( '\n' ) . filter ( Boolean ) )
712- . concat ( docker ( 'ps' , '--all' , '--quiet' , '--filter' , `label=io.codeboost.allocation=${ allocationId } ` ) . split ( '\n' )
713- . filter ( Boolean ) ) ;
714720
715721 it ( 'backs out of task storage when a concurrent process claims the same allocation ID' , async ( ) => {
716722 const data = fixture ( ) , owner = testOwner ( 'storage-race' ) , rival = `codeboost-race-${ randomUUID ( ) } ` ;
@@ -2382,8 +2388,9 @@ describe('real Docker agent isolation', () => {
23822388 const data = fixture ( ) ;
23832389 const clone = createTaskClone ( { source : data . source , parent : join ( data . root , 'staging' ) , taskId : 'task-late' ,
23842390 head : git ( data . source , 'rev-parse' , 'HEAD' ) } ) ;
2385- const keepers = ( ) => new Set ( docker ( 'ps' , '--all' , '--quiet' , '--filter' , 'label=io.codeboost.task-storage=keeper' )
2386- . split ( '\n' ) . filter ( Boolean ) ) ;
2391+ // Only this run's keepers: the orphans found are force-removed, so another run's must never be among them.
2392+ const keepers = ( ) => new Set ( docker ( 'ps' , '--all' , '--quiet' , '--filter' , 'label=io.codeboost.task-storage=keeper' ,
2393+ '--filter' , `label=io.codeboost.runner=${ TEST_RUNNER_OWNER } ` ) . split ( '\n' ) . filter ( Boolean ) ) ;
23872394 const before = keepers ( ) ;
23882395 const shim = join ( data . root , 'docker-shim' ) ; mkdirSync ( shim ) ;
23892396 const realDocker = execFileSync ( 'sh' , [ '-c' , 'command -v docker' ] , { encoding : 'utf8' } ) . trim ( ) ;
0 commit comments