mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
Merge remote-tracking branch 'origin/main' into garrytan/reliability-followups-oct4
# Conflicts: # CHANGELOG.md # VERSION # agents-digest/gstack-AGENTS.md # package.json
This commit is contained in:
commit
b8ea054772
20 files changed
+445
-82
No files matched your search
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { CONTAINER_SHM_BYTES, DockerGroup, type ContainerSpec } from '../lib/cso/docker';
|
||||
import { CONTAINER_SHM_BYTES, DockerGroup, preparedExportRejection, type ContainerSpec } from '../lib/cso/docker';
|
||||
import * as processModule from '../lib/cso/process';
|
||||
import { COMMAND_TIMEOUT_MS, PREPARATION_COMMAND_TIMEOUT_MS, commandTimeoutMs } from '../lib/cso/process';
|
||||
|
||||
const roots: string[] = [];
|
||||
const restores: Array<() => void> = [];
|
||||
@@ -22,6 +24,7 @@ function fixture() {
|
||||
restores.push(() => listener.stop(true));
|
||||
const image = `sha256:${'a'.repeat(64)}`, id = 'b'.repeat(64), calls: string[][] = [];
|
||||
let createArgs: string[] = [];
|
||||
const inspectHooks: Array<(value: any) => void> = [];
|
||||
const group = new (DockerGroup as any)({}, 'mount-regression', root, Date.now() + 60_000, {}) as DockerGroup;
|
||||
if (process.getuid!() === 0) {
|
||||
const uid = spyOn(process, 'getuid').mockReturnValue(1001);
|
||||
@@ -43,17 +46,25 @@ function fixture() {
|
||||
if (args[0] === 'create') { createArgs = args; return id; }
|
||||
if (args[0] === 'inspect') {
|
||||
const tmpfs = createArgs.flatMap((arg, index) => arg === '--tmpfs' ? [createArgs[index + 1].split(':')[0]] : []);
|
||||
const binds = createArgs.flatMap((arg, index) => arg === '--mount' ? [createArgs[index + 1]] : []);
|
||||
return JSON.stringify({
|
||||
HostConfig: { ReadonlyRootfs: true, ShmSize: CONTAINER_SHM_BYTES, Tmpfs: Object.fromEntries(tmpfs.map(target => [target, 'rw'])) },
|
||||
const mounts = createArgs.flatMap((arg, index) => arg === '--mount' ? [createArgs[index + 1]] : []);
|
||||
const binds = mounts.filter(mount => !mount.startsWith('type=volume,'));
|
||||
const volumes = mounts.filter(mount => mount.startsWith('type=volume,')).map(mount => ({
|
||||
target: /dst=([^,]+)/.exec(mount)![1], o: /"volume-opt=o=([^"]+)"/.exec(mount)![1],
|
||||
}));
|
||||
const value = {
|
||||
HostConfig: { ReadonlyRootfs: true, ShmSize: CONTAINER_SHM_BYTES, Tmpfs: Object.fromEntries(tmpfs.map(target => [target, 'rw'])),
|
||||
Mounts: [...binds.map(() => ({ Type: 'bind' })), ...volumes.map(volume => ({ Type: 'volume', Target: volume.target,
|
||||
VolumeOptions: { DriverConfig: { Name: 'local', Options: { device: 'tmpfs', o: volume.o, type: 'tmpfs' } } } }))] },
|
||||
Mounts: [...tmpfs.map(Destination => ({ Type: 'tmpfs', Destination })), ...binds.map(mount => ({
|
||||
Type: 'bind', Destination: mount.split(',').find(part => part.startsWith('dst='))!.slice(4),
|
||||
}))],
|
||||
});
|
||||
})), ...volumes.map(volume => ({ Type: 'volume', Destination: volume.target, Driver: 'local' }))],
|
||||
};
|
||||
for (const hook of inspectHooks) hook(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
throw new Error(`Unexpected Docker call: ${args.join(' ')}`);
|
||||
};
|
||||
return { root, directory, file, socket, group, image, id, calls };
|
||||
return { root, directory, file, socket, group, image, id, calls, inspectHooks };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('CSO Docker nonrecursive bind mounts', () => {
|
||||
@@ -106,4 +117,86 @@ describe.skipIf(process.platform === 'win32')('CSO Docker nonrecursive bind moun
|
||||
expect(f.calls.some(call => call[0] === 'create')).toBe(false);
|
||||
expect(fs.existsSync(path.join(f.root, 'resources.journal'))).toBe(false);
|
||||
});
|
||||
|
||||
// docker cp cannot read tmpfs mounts, so both export sources are tmpfs-backed local volumes.
|
||||
test.each([
|
||||
['acquisition archives', '/archives', { archiveTmpfsBytes: 4096 }, 'noexec,nosuid,nodev'],
|
||||
['prepared-tree work', '/work', { exportableWork: true as const }, 'nosuid,nodev'],
|
||||
])('%s export from a bounded in-memory volume docker cp can read', async (_name, target, spec, flags) => {
|
||||
const f = fixture();
|
||||
expect(await f.group.createContainer({ role: 'app', image: f.image, command: ['/bin/sleep', '1'], ...(spec as Partial<ContainerSpec>) })).toBe(f.id);
|
||||
const args = f.calls.find(call => call[0] === 'create')!;
|
||||
const volume = args.flatMap((arg, index) => arg === '--mount' && args[index + 1].startsWith('type=volume,') ? [args[index + 1]] : []);
|
||||
expect(volume).toHaveLength(1);
|
||||
expect(volume[0]).toStartWith(`type=volume,dst=${target},volume-driver=local,volume-opt=type=tmpfs,volume-opt=device=tmpfs,"volume-opt=o=size=`);
|
||||
expect(volume[0]).toEndWith(`,mode=700,uid=${process.getuid!()},gid=${process.getgid!()},${flags}"`);
|
||||
expect(args.flatMap((arg, index) => arg === '--tmpfs' ? [args[index + 1].split(':')[0]] : [])).not.toContain(target);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['dropped tmpfs driver options', (value: any) => { value.HostConfig.Mounts.at(-1).VolumeOptions.DriverConfig.Options.type = 'none'; }],
|
||||
['a host-backed volume source', (value: any) => { value.HostConfig.Mounts.at(-1).Source = '/var/tmp/export'; }],
|
||||
['an extra daemon volume', (value: any) => { value.Mounts.push({ Type: 'volume', Destination: '/data', Driver: 'local' }); }],
|
||||
['a missing volume mount', (value: any) => { value.HostConfig.Mounts.pop(); }],
|
||||
])('export volumes fail closed on %s', async (_name, mutate) => {
|
||||
const f = fixture(); f.inspectHooks.push(mutate);
|
||||
await expect(f.group.createContainer({ role: 'app', image: f.image, command: ['/bin/sleep', '1'], archiveTmpfsBytes: 4096 })).rejects.toThrow('Docker did not preserve');
|
||||
expect(fs.existsSync(path.join(f.root, 'resources.journal'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO preparation command ceiling', () => {
|
||||
test('only the preparation phase gets the longer ceiling, and neither outlives the deadline', () => {
|
||||
const now = 1_000_000, later = now + 2 * 60 * 60_000;
|
||||
expect(PREPARATION_COMMAND_TIMEOUT_MS).toBe(900_000);
|
||||
expect(COMMAND_TIMEOUT_MS).toBe(300_000);
|
||||
expect(commandTimeoutMs(later, 'command', now)).toBe(COMMAND_TIMEOUT_MS);
|
||||
expect(commandTimeoutMs(later, 'preparation', now)).toBe(PREPARATION_COMMAND_TIMEOUT_MS);
|
||||
expect(commandTimeoutMs(now + 60_000, 'preparation', now)).toBe(60_000);
|
||||
expect(commandTimeoutMs(now + 60_000, 'command', now)).toBe(60_000);
|
||||
expect(commandTimeoutMs(now - 1, 'preparation', now)).toBe(1);
|
||||
});
|
||||
|
||||
test('execCapture passes the preparation ceiling to the child only when asked, bounded by the group deadline', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-t-'));
|
||||
roots.push(root);
|
||||
const seen: Array<{ timeoutMs?: number; preparationCommand?: true }> = [];
|
||||
const run = spyOn(processModule, 'runProcess').mockImplementation(async (_file, _args, opts) => {
|
||||
seen.push({ timeoutMs: opts.timeoutMs, preparationCommand: opts.preparationCommand });
|
||||
return { code: 0, stdout: '', stderr: '', timedOut: false, truncated: false, capturedBytes: 0 };
|
||||
});
|
||||
restores.push(() => run.mockRestore());
|
||||
const group = (deadline: number) =>
|
||||
new (DockerGroup as any)({ executable: '/usr/bin/docker' }, 'timeout-ceiling', root, deadline, {}) as DockerGroup;
|
||||
const far = group(Date.now() + 2 * 60 * 60_000), near = group(Date.now() + 60_000);
|
||||
await far.execCapture('x', ['/bin/true']);
|
||||
await far.execCapture('x', ['/bin/true'], { preparationCommand: true });
|
||||
await near.execCapture('x', ['/bin/true'], { preparationCommand: true });
|
||||
expect(seen[0]).toEqual({ timeoutMs: COMMAND_TIMEOUT_MS, preparationCommand: undefined });
|
||||
expect(seen[1]).toEqual({ timeoutMs: PREPARATION_COMMAND_TIMEOUT_MS, preparationCommand: true });
|
||||
expect(seen[2].preparationCommand).toBe(true);
|
||||
expect(seen[2].timeoutMs!).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
test('only the acquisition and offline dependency command loops request the preparation ceiling', () => {
|
||||
const directory = path.join(import.meta.dir, '../lib/cso');
|
||||
const uses = fs.readdirSync(directory).filter(name => name.endsWith('.ts')).flatMap(name => {
|
||||
const lines = fs.readFileSync(path.join(directory, name), 'utf8').split('\n');
|
||||
return lines.flatMap((line, index) => line.includes('preparationCommand: true') ? [{ name, context: lines.slice(Math.max(0, index - 4), index).join('\n') }] : []);
|
||||
});
|
||||
expect(uses.map(use => use.name)).toEqual(['preparation-docker.ts', 'preparation-docker.ts']);
|
||||
for (const use of uses) expect(use.context).toContain('[command.executable, ...command.args]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSO prepared export rejection reason', () => {
|
||||
test('names the helper reason or errno and never echoes paths', () => {
|
||||
expect(preparedExportRejection('4 | throw new Error("prepared tree contains a hard-linked file");\n ^\nerror: prepared tree contains a hard-linked file\n at f (/$bunfs/root/preparation:4:9)\n'))
|
||||
.toBe('prepared tree contains a hard-linked file');
|
||||
expect(preparedExportRejection("ENOENT: no such file or directory, lstat '/work/secret-name'\n path: \"/work/secret-name\",\n syscall: \"lstat\",\n errno: -2,\n code: \"ENOENT\"\n"))
|
||||
.toBe('filesystem error ENOENT during lstat');
|
||||
expect(preparedExportRejection('error: prepared /work/secret-name\n')).toBe('unrecognized helper failure');
|
||||
expect(preparedExportRejection('error: prepared tree contains a FIFO, socket, device, or other special object\n')).toBe('prepared tree contains a FIFO, socket, device, or other special object');
|
||||
expect(preparedExportRejection('')).toBe('unrecognized helper failure');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createPreparedExport, recordNpmArchiveEntry } from '../lib/cso/preparation-container';
|
||||
import { materializePreparedExport } from '../lib/cso/preparation-docker';
|
||||
import { validateSingleContainerProcessOutput } from '../lib/cso/docker';
|
||||
import { heldApplicationReady, validateSingleContainerProcessOutput } from '../lib/cso/docker';
|
||||
|
||||
const roots: string[] = [];
|
||||
function temporary(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-prepared-export-')); roots.push(root); return root; }
|
||||
@@ -60,4 +60,11 @@ describe('CSO preparation container archive limits', () => {
|
||||
expect(() => validateSingleContainerProcessOutput('PID\n123\n456\n')).toThrow('background process');
|
||||
expect(() => validateSingleContainerProcessOutput('PID COMMAND\n123 bun\n')).toThrow('background process');
|
||||
});
|
||||
|
||||
test('treats a held application as ready only after run-app has finished copying its source', () => {
|
||||
expect(heldApplicationReady('PID COMMAND\n273296 sleep\n')).toBe(true);
|
||||
expect(heldApplicationReady('PID COMMAND\n1 sh\n2 cp\n')).toBe(false);
|
||||
expect(heldApplicationReady('PID COMMAND\n1 sh\n2 sleep\n')).toBe(false);
|
||||
expect(heldApplicationReady('PID COMMAND\n1 sh\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -366,6 +366,43 @@ describe('CSO constrained dependency preparation executor', () => {
|
||||
} finally { await broker.close(); }
|
||||
});
|
||||
|
||||
// Rails acquires ~70 gems in sequence. Under Bun, stream.pipe left each finished
|
||||
// tunnel's upstream open, so the broker hit its 64-connection limit mid-acquisition.
|
||||
test('finished registry tunnels release both sockets across more tunnels than the connection limit', async () => {
|
||||
const base = root('cso-broker-release-'), socketPath = path.join(base, 'registry.sock');
|
||||
const registry = net.createServer(upstream => { upstream.on('data', chunk => upstream.write(chunk)); upstream.on('end', () => upstream.end()); });
|
||||
await new Promise<void>(resolve => registry.listen(0, '127.0.0.1', resolve));
|
||||
const registryPort = (registry.address() as net.AddressInfo).port, dial = net.connect;
|
||||
const connect = spyOn(net, 'connect').mockImplementation(((options: any, ...rest: any[]) =>
|
||||
dial({ host: '127.0.0.1', port: options?.port === 443 ? registryPort : options?.port }, ...rest)) as typeof net.connect);
|
||||
const broker = new RegistryEgressBroker(socketPath, ['rubygems.org'], Date.now() + 60_000, 64 * 1024 * 1024,
|
||||
() => ({ promise: Promise.resolve([{ address: '151.101.1.227', family: 4 as const }]), cancel: () => {} }));
|
||||
await broker.start();
|
||||
try {
|
||||
for (let index = 0; index < 70; index++) {
|
||||
const echoed = await new Promise<string>((resolveEcho, reject) => {
|
||||
const client = net.createConnection({ path: socketPath }); let output = '';
|
||||
client.setTimeout(5_000, () => client.destroy(new Error(`tunnel ${index} stalled`)));
|
||||
client.once('connect', () => client.write('CONNECT rubygems.org:443 HTTP/1.1\r\nHost: rubygems.org:443\r\n\r\n'));
|
||||
client.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
if (output === 'HTTP/1.1 200 Connection Established\r\n\r\n') { client.write(`gem-${index}`); return; }
|
||||
if (output.endsWith(`gem-${index}`)) client.end();
|
||||
});
|
||||
client.once('close', () => resolveEcho(output)); client.once('error', reject);
|
||||
});
|
||||
expect(echoed).toEndWith(`gem-${index}`);
|
||||
}
|
||||
const deadline = Date.now() + 2_000;
|
||||
while ((broker as any).sockets.size && Date.now() < deadline) await Bun.sleep(10);
|
||||
expect((broker as any).sockets.size).toBe(0);
|
||||
expect(() => broker.assertClean()).not.toThrow();
|
||||
expect([...broker.contactedHosts]).toEqual(['rubygems.org']);
|
||||
} finally {
|
||||
connect.mockRestore(); await broker.close(); await new Promise<void>(resolve => registry.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('registry address classification rejects mapped, translated, private, and unspecified destinations', () => {
|
||||
for (const address of ['127.0.0.1', '169.254.169.254', '10.2.3.4', '100.64.0.1'])
|
||||
expect(isBlockedRegistryAddress(address, 4)).toBe(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { inspectPreparation, railsTestConfiguration } from '../lib/cso/preparation';
|
||||
@@ -59,6 +59,17 @@ describe('CSO inert Node preparation', () => {
|
||||
const plan = inspectPreparation(fixture(nodeFiles(1)));
|
||||
expect(plan.prerequisites[0].code).toBe('UNSUPPORTED_LOCK'); expect(plan.acquisition).toEqual([]); expect(plan.metadata).toEqual([]);
|
||||
});
|
||||
// npm 11 refuses one file loaded as both user and global config ("double-loading config").
|
||||
test('every npm command loads distinct empty user and global configs', () => {
|
||||
const plan = inspectPreparation(fixture(nodeFiles(3)));
|
||||
const npm = [...plan.acquisition, ...plan.offline].filter(command => command.executable === '/usr/local/bin/npm');
|
||||
expect(npm.length).toBeGreaterThanOrEqual(3);
|
||||
for (const command of npm) {
|
||||
const value = (flag: string) => command.args[command.args.indexOf(flag) + 1];
|
||||
expect([value('--userconfig'), value('--globalconfig')]).toEqual(['/opt/cso/empty-config', '/opt/cso/empty-globalconfig']);
|
||||
}
|
||||
expect(readFileSync(join(import.meta.dir, '../lib/cso/images/node.Dockerfile'), 'utf8')).toContain('touch /opt/cso/empty-config /opt/cso/empty-globalconfig');
|
||||
});
|
||||
for (const url of [CREDENTIAL_ARCHIVE_URL, 'http://registry.npmjs.org/a.tgz', 'https://registry.npmjs.org.evil.invalid/a.tgz', 'https://127.0.0.1/a.tgz', 'git+ssh://github.com/a/b', 'https://registry.npmjs.org/a.tgz?token=secret']) test(`rejects non-public archive ${url.split('@').at(-1)}`, () => {
|
||||
const files = nodeFiles(); files['package-lock.json'].packages['node_modules/cookie'].resolved = url;
|
||||
const plan = inspectPreparation(fixture(files)); expect(plan.status).toBe('prerequisites'); expect(plan.acquisition).toEqual([]);
|
||||
@@ -159,6 +170,12 @@ describe('CSO inert Rails preparation', () => {
|
||||
const plan = inspectPreparation(fixture({ 'Gemfile.lock': gemLock, 'Gemfile': 'system("touch /tmp/CSO_UNSAFE")\nsource "https://rubygems.org"' }));
|
||||
expect(plan.status).toBe('ready'); expect(plan.stack).toBe('rails'); expect(plan.metadata.some(m => m.path === 'Gemfile')).toBe(false);
|
||||
expect(plan.acquisition).toHaveLength(2); expect(plan.acquisition.every(c => c.args[0] === 'fetch')).toBe(true);
|
||||
// One gem process per platform: each process loads the full RubyGems index once.
|
||||
for (const command of plan.acquisition) {
|
||||
const platform = command.args[command.args.indexOf('--platform') + 1];
|
||||
expect(command.args.slice(1, command.args.indexOf('--platform'))).toEqual(plan.inputs.filter(input => input.platform === platform).map(input => `${input.name}:${input.version}`));
|
||||
expect(command.args).not.toContain('--version');
|
||||
}
|
||||
expect(plan.inputs[1].platform).toBe('x86_64-linux-gnu'); expect(plan.inputs[0].integritySource).toBe('registry-on-acquisition');
|
||||
expect(plan.runtimeRequirements).toEqual({ ruby: '3.3.6', bundler: '2.6.9' });
|
||||
expect(plan.offline[0].args).toContain('--local'); expect(plan.offline[0].env.BUNDLE_IGNORE_CONFIG).toBe('true');
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('CSO Rails database verification lifecycle',()=>{
|
||||
return {
|
||||
anchor:`anchor-${group}`,
|
||||
createContainer:async(spec:any)=>{const id=`${group}-${spec.role}-${++next}`;events.push(`create:${id}:${spec.command.join(' ')}`);if(spec.postgresDatabasePolicy){policies.push(fs.readFileSync(spec.postgresDatabasePolicy,'utf8').trim().split('\n'));expect(fs.statSync(spec.postgresDatabasePolicy).mode&0o777).toBe(0o444);}return id;},
|
||||
start:async(id:string)=>{events.push(`start:${id}`);},
|
||||
start:async(id:string)=>{events.push(`start:${id}`);},startHeldApplication:async(id:string)=>{events.push(`held:${id}`);},
|
||||
execCapture:async(id:string,command:string[])=>{events.push(`exec:${id}:${command.join(' ')}`);return{code:0,stdout:'',stderr:''};},
|
||||
execDetached:async(id:string,command:string[])=>{events.push(`detach:${id}:${command.join(' ')}`);},
|
||||
startAttach:async(id:string)=>{events.push(`attach:${id}`);return{code:0,output:JSON.stringify({booted:true,legitimate:true,security:'pass',existingTests:false,output:'ok',inputHash:''})};},
|
||||
@@ -37,6 +37,8 @@ describe('CSO Rails database verification lifecycle',()=>{
|
||||
expect(policies).toEqual([['cso_primary','cso_queue'],['cso_primary','cso_queue']]);
|
||||
expect(events.filter(event=>event.includes('/opt/cso/postgresql-ready'))).toHaveLength(2);
|
||||
expect(events.filter(event=>event.includes('/usr/local/bin/bundle exec rails db:prepare'))).toHaveLength(6);
|
||||
for(const [index,event] of events.entries())if(event.includes('exec rails db:prepare'))expect(events.slice(0,index)).toContain(`held:${event.split(':')[1]}`);
|
||||
expect(events.some(event=>/^start:\d-(app|tests)-/.test(event))).toBe(false);
|
||||
for(const group of [1,2]){
|
||||
const removeApp=events.findIndex(event=>event===`remove:${group}-app-2`),createFirstTest=events.findIndex(event=>event.startsWith(`create:${group}-tests-4:`));
|
||||
expect(removeApp).toBeGreaterThan(-1);expect(createFirstTest).toBeGreaterThan(removeApp);
|
||||
@@ -47,11 +49,11 @@ describe('CSO Rails database verification lifecycle',()=>{
|
||||
const root=fs.mkdtempSync(path.join(os.tmpdir(),'cso-rails-sqlite-'));roots.push(root);for(const name of ['source','work','control'])fs.mkdirSync(path.join(root,name));
|
||||
const commands:string[]=[];let next=0;
|
||||
const create=spyOn(DockerGroup,'create').mockResolvedValue({
|
||||
createContainer:async(spec:any)=>{commands.push(`create:${spec.role}`);return`${spec.role}-${++next}`;},start:async()=>{},
|
||||
createContainer:async(spec:any)=>{commands.push(`create:${spec.role}`);return`${spec.role}-${++next}`;},start:async()=>{},startHeldApplication:async(id:string)=>{commands.push(`held:${id}`);},
|
||||
execCapture:async(_id:string,command:string[])=>{commands.push(command.join(' '));return{code:0,stdout:'',stderr:''};},execDetached:async()=>{},
|
||||
startAttach:async()=>({code:0,output:JSON.stringify({booted:true,legitimate:true,security:'pass',existingTests:false,output:'ok',inputHash:''})}),removeContainer:async()=>{},cleanup:async()=>{},
|
||||
} as any);
|
||||
try{const runtime:any={stack:'rails',image:`runtime@sha256:${'a'.repeat(64)}`},request:any={findingId:'d'.repeat(32),port:3456,start:{executable:'/usr/local/bin/bundle',args:['exec','rails','server']},legitimate:[],security:{},fixtures:{},existingTests:[{executable:'/usr/local/bin/bundle',args:['exec','rails','test']}]};await new DockerVerificationExecutor({} as any,'/watchdog',Date.now()+60_000).observe(path.join(root,'source'),'before',request,runtime,runtime,path.join(root,'work'),path.join(root,'control'),{environment:{},database:{adapter:'sqlite',connections:['primary']}});}finally{create.mockRestore();}
|
||||
expect(commands.some(command=>command==='create:postgres')).toBe(false);expect(commands.filter(command=>command.includes('exec rails db:prepare'))).toHaveLength(2);
|
||||
expect(commands.some(command=>command==='create:postgres')).toBe(false);expect(commands.filter(command=>command.includes('exec rails db:prepare'))).toHaveLength(2);expect(commands.filter(command=>command.startsWith('held:'))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
type PreparedApplication, type PreparationRuntimeAdmission, type RailsDatabaseSelection,
|
||||
} from '../lib/cso/preparation-executor';
|
||||
import { inspectPreparation, type CsoStack, type PreparationPlan } from '../lib/cso/preparation';
|
||||
import { CSO_HELPER_ABI, type QualifiedRuntime, type RuntimeCatalog } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './helpers/cso-runtime-catalog';
|
||||
import { CSO_HELPER_ABI, validateRuntimeCatalog, type QualifiedRuntime, type RuntimeCatalog } from '../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture, installStagedRuntime } from './helpers/cso-runtime-catalog';
|
||||
import { secureDirectory } from '../lib/cso/state';
|
||||
import { canonicalStartPlan, canonicalTestPlan, DockerVerificationExecutor } from '../lib/cso/verification';
|
||||
|
||||
@@ -82,7 +82,10 @@ import django
|
||||
django.setup()
|
||||
from django.test import Client
|
||||
class ColdStartTest(unittest.TestCase):
|
||||
def test_dependency_and_control(self): self.assertContains(Client().get("/control"),"CONTROL_OK",status_code=200)
|
||||
def test_dependency_and_control(self):
|
||||
response = Client().get("/control")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"CONTROL_OK", response.content)
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -102,6 +105,7 @@ function railsSource(directory: string, bundlerVersion: string): void {
|
||||
'config/database.yml': `default: &default\n pool: 3\ntest:\n primary:\n <<: *default\n adapter: sqlite3\n database: storage/test.sqlite3\n queue:\n <<: *default\n adapter: postgresql\n database: cso_queue\n`,
|
||||
'app/controllers/application_controller.rb': `class ApplicationController < ActionController::Base;end\n`,
|
||||
'app/controllers/probe_controller.rb': `class ProbeController < ApplicationController\n def control;render plain:"CONTROL_OK:Rails";end\n def security;render plain:"DENIED",status: :forbidden;end\nend\n`,
|
||||
'bin/rails': `#!/usr/bin/env ruby\nAPP_PATH = File.expand_path("../config/application", __dir__)\nrequire_relative "../config/boot"\nrequire "rails/commands"\n`,
|
||||
'Rakefile': `require_relative "config/application"\nRails.application.load_tasks\n`,
|
||||
'config.ru': `require_relative "config/environment"\nrun Rails.application\n`,
|
||||
'db/schema.rb': `ActiveRecord::Schema[8.1].define(version: 1) do\n create_table :cold_records, force: true do |t|\n t.string :name\n end\nend\n`,
|
||||
@@ -163,21 +167,14 @@ beforeAll(async () => {
|
||||
if (!fs.existsSync(watchdog)) throw new Error(`${requestedStack} cold-start qualification requires the compiled CSO watchdog`);
|
||||
endpoint = await dockerEndpoint(secureDirectory(path.join(root, 'docker-home')), { HOME: root, DOCKER_HOST: process.env.DOCKER_HOST ?? 'unix:///var/run/docker.sock' });
|
||||
catalog = completeRuntimeCatalogFixture(`${requestedStack}-staged-cold`);
|
||||
const installRuntime = (value: QualifiedRuntime): QualifiedRuntime => {
|
||||
const runtimeIndex = catalog.runtimes.findIndex(item => item.stack === value.stack && item.platform === value.platform);
|
||||
const profile = catalog.profiles.find(item => item.stack === value.stack && item.platform === value.platform)!;
|
||||
const installed = { ...value, id: profile.id };
|
||||
catalog.runtimes[runtimeIndex] = installed;
|
||||
profile.versions = { ...installed.versions };
|
||||
return installed;
|
||||
};
|
||||
runtime = installRuntime(qualified(requestedStack as CsoStack, image, versions));
|
||||
runtime = installStagedRuntime(catalog, qualified(requestedStack as CsoStack, image, versions));
|
||||
if (requestedStack === 'rails') {
|
||||
const postgresImage = process.env.GSTACK_CSO_TEST_POSTGRES_IMAGE;
|
||||
const postgresVersion = process.env.GSTACK_CSO_TEST_POSTGRES_VERSION;
|
||||
if (!postgresImage || !/^\d+\.\d+(?:\.\d+)?$/.test(postgresVersion ?? '')) throw new Error('Rails cold-start qualification requires a staged PostgreSQL digest and exact version');
|
||||
installRuntime(qualified('postgresql', postgresImage, { postgresql: postgresVersion! }));
|
||||
installStagedRuntime(catalog, qualified('postgresql', postgresImage, { postgresql: postgresVersion! }));
|
||||
}
|
||||
validateRuntimeCatalog(catalog);
|
||||
});
|
||||
afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); delete process.env.GSTACK_HOME; });
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { dispatchCsoCommand, type CsoCliDependencies } from '../../lib/cso/cli';
|
||||
import { canonical, sha256 } from '../../lib/cso/contracts';
|
||||
import { validateRuntimeCatalog, type QualifiedRuntime, type RuntimeCatalog, type RuntimePlatform } from '../../lib/cso/runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture } from './cso-runtime-catalog';
|
||||
import { completeRuntimeCatalogFixture, installStagedRuntime } from './cso-runtime-catalog';
|
||||
|
||||
export interface QualifiedCsoCli {
|
||||
readonly catalog: RuntimeCatalog;
|
||||
@@ -31,17 +30,9 @@ export function qualifiedNodeCli(options: {
|
||||
}
|
||||
|
||||
const catalog = completeRuntimeCatalogFixture('node-lifecycle-fixture');
|
||||
const runtimeIndex = catalog.runtimes.findIndex(item => item.stack === 'node' && item.platform === options.platform);
|
||||
const profile = catalog.profiles.find(item => item.stack === 'node' && item.platform === options.platform);
|
||||
if (runtimeIndex < 0 || !profile || !catalog.promotion) throw new Error('Node lifecycle catalog fixture is incomplete');
|
||||
const runtime: QualifiedRuntime = {
|
||||
...catalog.runtimes[runtimeIndex],
|
||||
image: options.image,
|
||||
versions: { ...options.versions },
|
||||
};
|
||||
catalog.runtimes[runtimeIndex] = runtime;
|
||||
profile.versions = { ...runtime.versions };
|
||||
catalog.promotion.evidenceDigest = `sha256:${sha256(canonical(catalog.runtimes))}`;
|
||||
const fixture = catalog.runtimes.find(item => item.stack === 'node' && item.platform === options.platform);
|
||||
if (!fixture) throw new Error('Node lifecycle catalog fixture is incomplete');
|
||||
const runtime = installStagedRuntime(catalog, { ...fixture, image: options.image, versions: options.versions });
|
||||
validateRuntimeCatalog(catalog);
|
||||
|
||||
const dependencies: CsoCliDependencies = Object.freeze({
|
||||
|
||||
@@ -107,3 +107,19 @@ export function completeRuntimeCatalogFixture(
|
||||
runtimes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a staged runtime into its stack/platform slot under the reviewed profile
|
||||
* id, as promotion does, and re-bind the promotion evidence digest the
|
||||
* validator recomputes from the runtime matrix.
|
||||
*/
|
||||
export function installStagedRuntime(catalog: RuntimeCatalog, runtime: QualifiedRuntime): QualifiedRuntime {
|
||||
const index = catalog.runtimes.findIndex(item => item.stack === runtime.stack && item.platform === runtime.platform);
|
||||
const profile = catalog.profiles.find(item => item.stack === runtime.stack && item.platform === runtime.platform);
|
||||
if (index < 0 || !profile || !catalog.promotion) throw new Error(`Runtime catalog fixture lacks ${runtime.stack} on ${runtime.platform}`);
|
||||
const installed = { ...runtime, id: profile.id, versions: { ...runtime.versions } };
|
||||
catalog.runtimes[index] = installed;
|
||||
profile.versions = { ...installed.versions };
|
||||
catalog.promotion.evidenceDigest = `sha256:${sha256(canonical(catalog.runtimes))}`;
|
||||
return installed;
|
||||
}
|
||||
@@ -178,7 +178,7 @@ http.createServer((req, res) => {
|
||||
expect(report.policy.diff).toBe(true);
|
||||
expect(report.policy.base).toBe('main');
|
||||
expect(report.source.baseCommit).toBe(git(f.repo, 'rev-parse', 'main').trim());
|
||||
expect(report.findings.some(item => item.location.path === 'webhook.js' && /signature|authenticat|forg/i.test(`${item.rootCause} ${item.impact}`))).toBe(true);
|
||||
expect(report.findings.some(item => item.location.path === 'webhook.js' && /signature|authentic|forg|spoof|hmac/i.test(`${item.title} ${item.rootCause} ${item.impact}`))).toBe(true);
|
||||
expect(report.findings.every(item => item.location.path === 'webhook.js')).toBe(true);
|
||||
passed = result.browseErrors.length === 0;
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user