Files
gstack/test/ubicloud-runner.test.ts
T
Garry Tanandcapy-ai[bot] 5ba425af4b v1.91.376.0 feat(ubicloud): reattach to a live verify VM and checkpoint tiers (GSTA-158 P3) (#3615)
* feat(ubicloud): reattach to a live verify VM and checkpoint tiers (GSTA-158 P3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ubicloud): replay on reattach, tear down TTL-less VMs on HUP, trim skill prose (GSTA-158 P3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 17:05:40 +00:00

1117 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Static and offline pins for `bun run test:ubicloud` (scripts/ubicloud/).
* The VM path needs a Ubicloud token and costs money, so it is exercised by
* hand; these checks keep its environment from drifting away from the
* required CI free lane it mirrors, and prove it fails before any network
* call when the token is absent.
*/
import { describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
const DIR = join(ROOT, 'scripts/ubicloud');
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
type Step = { uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> };
const workflow = Bun.YAML.parse(read('.github/workflows/free-tests.yml')) as { jobs: Record<string, { steps: Step[] }> };
const freeSuite = workflow.jobs['free-suite'].steps;
describe('ubicloud free-suite runner', () => {
test('setup pins the same Bun version as the CI free-suite job', () => {
const ciBun = freeSuite.find(step => step.uses?.startsWith('oven-sh/setup-bun'))?.with?.['bun-version'];
expect(ciBun).toBeDefined();
expect(read('scripts/ubicloud/setup-free-suite.sh')).toContain(`BUN_VERSION=${ciBun}\n`);
});
test('setup performs the CI job’s build steps', () => {
const setup = read('scripts/ubicloud/setup-free-suite.sh');
for (const command of ['bun install --frozen-lockfile', 'bun run gen:skill-docs --host all', 'bun run vendor:xterm',
'bash browse/scripts/build-node-server.sh', 'bun run build:gates', 'bun run build:cso']) {
expect(freeSuite.some(step => step.run?.includes(command))).toBe(true);
expect(setup).toContain(command);
}
expect(setup).toContain('chrome-sandbox');
expect(setup).toContain('kernel.apparmor_restrict_unprivileged_userns=0');
});
test('the wrapper runs the suite under Xvfb with the CI lane’s strictness knobs', () => {
const ciEnv = freeSuite.find(step => step.run?.includes('bun run test:free'))?.env ?? {};
const wrapper = read('scripts/ubicloud/test-free.sh');
expect(ciEnv.GSTACK_EXPECT_BINARIES).toBe('1');
expect(ciEnv.GSTACK_FREE_RETRY_FLAKY).toBe('1');
expect(wrapper).toContain('--env GSTACK_EXPECT_BINARIES=1');
expect(wrapper).toContain('--env GSTACK_FREE_RETRY_FLAKY=1');
expect(wrapper).toContain('--env GSTACK_FLAKE_LEDGER=/tmp/gstack-free-test-flake-ledger.jsonl');
expect(wrapper).toContain('--pull "/tmp/gstack-free-test-*:$logs"');
expect(wrapper).toContain('--pull "work/$(basename "$root")/.context/free-test-logs:$logs"');
expect(wrapper).toContain('xvfb-run -a bun run test:free');
expect(JSON.parse(read('package.json')).scripts['test:ubicloud']).toBe('bash scripts/ubicloud/test-free.sh');
});
test('remote commands force umask 022 and teardown is trapped on exit', () => {
const runner = read('scripts/ubicloud/ubi-runner.sh');
expect(runner).toContain('"umask 022; $*"');
// Armed before the create request and shielded from a second signal (record-before-create teardown, plan E2).
expect(runner).toContain(`trap 'run_teardown "$?"' EXIT`);
expect(runner.indexOf(`trap 'run_teardown "$?"' EXIT`)).toBeLessThan(runner.indexOf('cmd_up "${up_args[@]}" >/dev/null'));
const teardown = runner.slice(runner.indexOf('run_teardown() {'), runner.indexOf('\n}\n', runner.indexOf('run_teardown() {')));
expect(teardown.indexOf('trap "" INT TERM HUP QUIT')).toBeLessThan(teardown.indexOf('cmd_down "$RUN_VM"'));
});
test('pull retrieves the retained free-test logs and skips a glob that matches nothing', () => {
const root = mkdtempSync(join(tmpdir(), 'ubi-pull-'));
try {
const remoteHome = join(root, 'remote'), bin = join(root, 'bin'), state = join(root, 'state'), local = join(root, 'local');
mkdirSync(join(remoteHome, 'work/gstack/.context/free-test-logs'), { recursive: true });
mkdirSync(join(remoteHome, 'tmp'), { recursive: true });
writeFileSync(join(remoteHome, 'work/gstack/.context/free-test-logs/gstack-free-test-shard-11.log'), 'shard 11 log\n');
mkdirSync(join(state, 'fake-vm'), { recursive: true });
writeFileSync(join(state, 'fake-vm/env'), 'IP=192.0.2.1\n');
mkdirSync(bin);
writeFileSync(join(bin, 'ssh'), `#!/usr/bin/env bash\ncd ${JSON.stringify(remoteHome)} && exec bash -c "\${@: -1}"\n`);
chmodSync(join(bin, 'ssh'), 0o755);
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state };
const pull = (from: string) => Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'pull', 'fake-vm', from, local], { env, timeout: 10_000 });
const empty = pull(join(remoteHome, 'tmp/gstack-free-test-*'));
expect(empty.exitCode).toBe(0);
expect(empty.stderr.toString()).toContain('pull: nothing matches');
const logs = pull('work/gstack/.context/free-test-logs');
expect(logs.exitCode).toBe(0);
expect(readFileSync(join(local, 'free-test-logs/gstack-free-test-shard-11.log'), 'utf8')).toBe('shard 11 log\n');
expect(existsSync(join(local, 'gstack-free-test-*'))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('refuses to start without UBICLOUD_API_KEY, before any network call', () => {
const env = { ...process.env, UBICLOUD_API_URL: 'http://127.0.0.1:9' } as Record<string, string | undefined>;
delete env.UBICLOUD_API_KEY;
const result = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'list'], { env, timeout: 10_000 });
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain('UBICLOUD_API_KEY is not set');
expect(result.stdout.toString()).toBe('');
});
test('up and run accept only the storage sizes Ubicloud offers, before any API call; UBI_ALLOWED_STORAGE widens the list', () => {
const root = mkdtempSync(join(tmpdir(), 'ubi-storage-'));
try {
const bin = join(root, 'bin'), log = join(root, 'curl.log');
mkdirSync(bin);
writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash\necho "curl $*" >> ${JSON.stringify(log)}\necho 000\n`);
chmodSync(join(bin, 'curl'), 0o755);
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(root, 'state'), UBI_VM_RECORD: join(root, 'vms.tsv'), UBI_OWNER: 't' };
const ubi = (args: string[], extra: Record<string, string> = {}) => Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, timeout: 10_000 });
for (const args of [['up', '-S', '100'], ['run', '--storage', '1000', '--', 'true'], ['up', '-S', '']]) {
const r = ubi(args);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toContain('(allowed: 320 640; set UBI_ALLOWED_STORAGE when Ubicloud adds sizes)');
}
expect(existsSync(log)).toBe(false);
for (const [size, extra] of [['320', {}], ['1280', { UBI_ALLOWED_STORAGE: '320 640 1280' }]] as const) {
const accepted = ubi(['run', '-S', size, '--bogus'], extra);
expect(accepted.stderr.toString()).toContain('run: unknown option --bogus');
}
expect(existsSync(log)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('ubi-runner teardown is confirmed and never sweeps other runners by default', () => {
/**
* Fake Ubicloud API: `vm list` returns `listed`; `vm <loc>/<name> show` is 200
* while the name is listed and 404 after a successful destroy; `destroy`
* returns `destroy`. Every call body is appended to calls.log.
*/
function fakeApi(mode: { destroy: number; listed: string; owner?: string }) {
const root = mkdtempSync(join(tmpdir(), 'ubi-down-'));
const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log'), record = join(root, 'vms.tsv');
mkdirSync(bin); mkdirSync(join(state, 'vm-a'), { recursive: true });
writeFileSync(join(state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nSIZE=standard-16\nIP=192.0.2.1\nCREATED=yes\nCREATE_AT=0\nCREATE_PID=1\n');
writeFileSync(join(root, 'listed'), mode.listed);
writeFileSync(join(bin, 'curl'), `#!/usr/bin/env bash
out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done
body=$(cat); echo "$body" >> ${JSON.stringify(log)}
listed=${JSON.stringify(join(root, 'listed'))}
name=$(printf '%s' "$body" | sed -n 's/.*"vm", *"[^/"]*\\/\\([^"]*\\)".*/\\1/p')
case "$body" in
*destroy*) : > "$out"; if [ ${mode.destroy} = 200 ]; then grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; fi; printf '%s' ${mode.destroy} ;;
*'"list"'*) cat "$listed" > "$out"; printf 200 ;;
*'"show"'*) if grep -q " $name$" "$listed"; then printf 'state: running\nsize: standard-16\nip4: 192.0.2.1\n' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;
*) : > "$out"; printf 200 ;;
esac
`);
chmodSync(join(bin, 'curl'), 0o755);
const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state, UBI_POLL_SECONDS: '1', UBI_VM_RECORD: record, UBI_OWNER: mode.owner ?? 'thrd1' };
const run = (...args: string[]) => Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env, timeout: 90_000 });
return { root, run, record, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') };
}
test('down fails when the destroy request fails', () => {
const f = fakeApi({ destroy: 500, listed: 'eu-central-h1 vm-a\n' });
try {
const r = f.run('down', 'vm-a');
expect(r.exitCode).not.toBe(0);
expect(r.stderr.toString()).toContain('FAILED to destroy eu-central-h1/vm-a');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('down succeeds only once the VM is no longer listed, and forgets its scratch record', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\neu-central-h1 other-vm\n' });
try {
writeFileSync(f.record, 'vm-a\teu-central-h1\tthrd1\t2026-10-10T00:00:00Z\t1\nubirun-other-1000000000-aa\teu-central-h1\tother\t2026-10-10T00:00:00Z\t2\n');
const r = f.run('down', 'vm-a');
expect(r.exitCode).toBe(0);
expect(r.stderr.toString()).toContain('destroyed eu-central-h1/vm-a');
expect(readFileSync(f.record, 'utf8')).toBe('ubirun-other-1000000000-aa\teu-central-h1\tother\t2026-10-10T00:00:00Z\t2\n');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('gc with no hours and no UBI_GC_HOURS lists and destroys nothing', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 ubirun-1000000000-deadbeef\n' });
try {
expect(f.run('gc').exitCode).toBe(0);
expect(f.calls()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('gc destroys only this owner’s stale VMs: another owner’s and untagged legacy names are never touched', () => {
const listed = 'eu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-other-1000000000-bbbb\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-9999999999-dddd\n';
const f = fakeApi({ destroy: 200, listed });
try {
const r = f.run('gc', '1');
expect(r.exitCode).toBe(0);
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-thrd1-1000000000-aaaa');
expect(f.calls()).not.toMatch(/destroy.*ubirun-other/);
expect(f.calls()).not.toMatch(/destroy.*ubirun-1000000000-cccc/);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('list --mine shows this owner’s VMs only; owner tags are lowercase letters and digits, 12 max, starting with a letter', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-north-h1 ubirun-other-1000000000-bbbb\neu-central-h1 ubirun-1000000000-cccc\n' });
try {
const mine = f.run('list', '--mine');
expect(mine.exitCode).toBe(0);
expect(mine.stdout.toString()).toBe('eu-central-h1 ubirun-thrd1-1000000000-aaaa\n');
const owner = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'owner'], { env: { ...process.env, UBI_OWNER: '12-Capy_Thread-ABCDEFGH', UBI_RUNNER_STATE: f.root }, timeout: 10_000 });
expect(owner.stdout.toString().trim()).toBe('u12capythrea');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('up reaps expired TTL VMs of any owner first, names its own VM with -t<hours>, and the quota preflight refuses before any create request', () => {
const listed = 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-peer-9999999999-cccc\n';
const f = fakeApi({ destroy: 200, listed });
try {
const r = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '31' }, timeout: 90_000 });
expect(r.exitCode).toBe(1);
const err = r.stderr.toString();
expect(err).toMatch(/reaping expired eu-central-h1\/ubirun-other-1000000000-bbbb-t1 \(owner other; its TTL ended \d+ min ago\)/);
expect(err).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 16 of 31/);
expect(err).toContain('create refused before any request: vCPU quota exhausted');
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy'));
expect(destroyed).toHaveLength(1);
expect(destroyed[0]).toContain('ubirun-other-1000000000-bbbb-t1');
expect(f.calls()).not.toContain('"create"');
expect(existsSync(f.record)).toBe(false);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('UBI_REAP=0 skips the reap and UBI_TTL_HOURS sets the name\'s TTL; reap destroys expired TTLs and this owner\'s old TTL-less names, never unexpired, recent, another owner\'s TTL-less, untagged or foreign ones', () => {
const now = Math.floor(Date.now() / 1000);
const listed = `eu-central-h1 ubirun-other-1000000000-bbbb-t1\neu-central-h1 ubirun-thrd1-9999999999-dddd-t1\neu-central-h1 ubirun-thrd1-1000000000-aaaa\neu-central-h1 ubirun-1000000000-cccc\neu-central-h1 ubirun-thrd1-${now - 3 * 3600}-eeee\neu-central-h1 ubirun-gbra240-1000000000-gggg\neu-central-h1 team-db-1000000000-ffff\n`;
const f = fakeApi({ destroy: 200, listed });
try {
const up = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'up'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_QUOTA_VCPUS: '112', UBI_REAP: '0', UBI_TTL_HOURS: '7' }, timeout: 90_000 });
expect(up.exitCode).toBe(1);
expect(up.stderr.toString()).toMatch(/quota preflight refused eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t7 \(standard-16\): it needs 16 vCPUs and the project's VMs already use 112 of 112/);
expect(f.calls()).not.toContain('destroy');
const reap = f.run('reap');
expect(reap.exitCode).toBe(0);
expect(reap.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-1000000000-aaaa \(owner thrd1; \d+h old, legacy sweep at 4h\)/);
const destroyed = f.calls().split('\n').filter(l => l.includes('destroy')).map(l => l.match(/ubirun-[a-z0-9-]+|team-db-[a-z0-9-]+/)?.[0]).sort();
expect(destroyed).toEqual(['ubirun-other-1000000000-bbbb-t1', 'ubirun-thrd1-1000000000-aaaa']);
expect(f.run('reap').stderr.toString()).toContain('reap: no expired VMs');
// Value: protects=UBI_LEGACY_HOURS sets reap's bound for TTL-less ubirun names (a 3h-old name is spared at 4h, reaped at 2h); fails_when=reap ignores UBI_LEGACY_HOURS and keeps a fixed bound; why_new=only the default bound and the validation are tested; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
const lowered = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: 'thrd1', UBI_LEGACY_HOURS: '2' }, timeout: 90_000 });
expect(lowered.exitCode).toBe(0);
expect(lowered.stderr.toString()).toMatch(/reaping TTL-less eu-central-h1\/ubirun-thrd1-\d{10}-eeee \(owner thrd1; 3h old, legacy sweep at 2h\)/);
expect(f.calls()).toMatch(/ubirun-thrd1-\d{10}-eeee[^\n]*destroy/);
// Value: protects=the legacy sweep never destroys another lane's or an untagged TTL-less VM (GST-324: 22 gbra240 eval VMs lost); fails_when=the sweep matches any owner again; why_new=the cross-owner sweep destroyed a paid eval; seam=none; no_claim=real Ubicloud list and destroy (fake curl)
expect(f.calls()).not.toMatch(/(team-db|dddd|gggg|cccc)[^\n]*destroy/);
const ownerless = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'reap'], { env: { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_VM_RECORD: f.record, UBI_OWNER: '', UBI_LEGACY_HOURS: '1' }, timeout: 90_000 });
expect(ownerless.exitCode).toBe(0);
expect(ownerless.stderr.toString()).not.toContain('reaping TTL-less');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
test('up and run refuse without UBI_OWNER before any API call; list, usage, reap and down still work without it', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\n', owner: '' });
try {
for (const args of [['up'], ['run', '--', 'true']]) {
const r = f.run(...args);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toContain('UBI_OWNER is not set: every VM must carry its owner; export UBI_OWNER=<your thread or session code>');
expect(r.stderr.toString()).toContain('(UBI_OWNER_REQUIRED)');
}
expect(f.calls()).toBe('');
for (const args of [['list'], ['usage'], ['reap']]) expect(f.run(...args).exitCode).toBe(0);
expect(f.run('down', 'vm-a').exitCode).toBe(0);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('the watchdog kills a silent remote command with exit 124 and the last output, and the heartbeat reports progress', () => {
const f = fakeApi({ destroy: 200, listed: 'eu-central-h1 vm-a\n' });
try {
const ssh = join(f.root, 'bin/ssh');
writeFileSync(ssh, '#!/usr/bin/env bash\ncase "${*: -1}" in *pkill*) echo "$*" >> ' + JSON.stringify(join(f.root, 'cancel.log')) + '; exit 0 ;; esac\necho "line one"; echo "warn one" >&2; sleep 15\n');
chmodSync(ssh, 0o755);
const env = { ...process.env, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(f.root, 'state'), UBI_POLL_SECONDS: '1', UBI_OWNER: 'thrd1' };
const hung = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'ssh', 'vm-a', 'sleep 15'], { env: { ...env, UBI_IDLE_TIMEOUT: '2' }, timeout: 60_000 });
expect(hung.exitCode).toBe(124);
expect(hung.stdout.toString()).toBe('line one\n');
const err = hung.stderr.toString();
expect(err).toContain('warn one');
expect(err).toMatch(/watchdog: ssh printed nothing for \d+s \(UBI_IDLE_TIMEOUT=2\); killing it. Last output:/);
expect(err.indexOf('line one')).toBeGreaterThan(err.indexOf('Last output:'));
expect(readFileSync(join(f.root, 'state/vm-a/ssh.log'), 'utf8')).toContain('line one');
expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/sid=\$\(cat ~\/\.ubirun-ssh-\d+\.sid .*pkill -TERM -s/);
writeFileSync(ssh, '#!/usr/bin/env bash\nfor i in 1 2 3 4 5 6; do echo "tick $i"; sleep 0.5; done\n');
const beating = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'ssh', 'vm-a', 'true'], { env: { ...env, UBI_IDLE_TIMEOUT: '0', UBI_HEARTBEAT: '1' }, timeout: 60_000 });
expect(beating.exitCode).toBe(0);
expect(beating.stdout.toString()).toContain('tick 6');
expect(beating.stderr.toString()).toMatch(/heartbeat: ssh \d+s elapsed, last output \d+s ago/);
expect(f.calls()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
test('verify runs only a committed .gstack/ubicloud.json and resolves its setup script against --src, before any API call', () => {
const f = fakeApi({ destroy: 200, listed: '' });
try {
const src = join(f.root, 'repo');
mkdirSync(join(src, '.gstack'), { recursive: true });
const missing = f.run('verify', '--src', src);
expect(missing.exitCode).toBe(1);
expect(missing.stderr.toString()).toContain(`verify: no ${src}/.gstack/ubicloud.json; commit one`);
writeFileSync(join(src, '.gstack/ubicloud.json'), JSON.stringify({ setup: 'scripts/boot.sh' }));
const noCommand = f.run('verify', '--src', src);
expect(noCommand.exitCode).toBe(1);
expect(noCommand.stderr.toString()).toContain('names no command and none was given after --');
writeFileSync(join(src, '.gstack/ubicloud.json'), JSON.stringify({ setup: 'scripts/boot.sh', command: 'make verify', size: 'standard-8' }));
const noSetup = f.run('verify', '--src', src);
expect(noSetup.exitCode).toBe(1);
expect(noSetup.stderr.toString()).toContain(`setup script not found: ${src}/scripts/boot.sh`);
writeFileSync(join(src, '.gstack/ubicloud.json'), '{');
expect(f.run('verify', '--src', src).stderr.toString()).toContain('is not valid JSON');
expect(f.calls()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test('bin/gstack-ubi is a thin entry point: it execs the runner beside it and forwards every argument', () => {
const wrapper = read('bin/gstack-ubi');
expect(wrapper).toContain('exec bash "$here/../scripts/ubicloud/ubi-runner.sh" "$@"');
const help = Bun.spawnSync(['bash', join(ROOT, 'bin/gstack-ubi'), 'help'], { env: { ...process.env, UBI_RUNNER_STATE: mkdtempSync(join(tmpdir(), 'ubi-help-')) }, timeout: 10_000 });
expect(help.exitCode).toBe(0);
expect(help.stdout.toString()).toContain('usage: ubi-runner.sh <command> [args]');
const owner = Bun.spawnSync(['bash', join(ROOT, 'bin/gstack-ubi'), 'owner'], { env: { ...process.env, UBI_OWNER: 'lane7', UBI_RUNNER_STATE: mkdtempSync(join(tmpdir(), 'ubi-owner-')) }, timeout: 10_000 });
expect(owner.stdout.toString().trim()).toBe('lane7');
});
test('pack ships a linked worktree with a self-contained .git on the checked-out branch, carrying its index', () => {
const d = mkdtempSync(join(tmpdir(), 'ubi-pack-'));
try {
const sh = (cmd: string, cwd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } }); expect(r.exitCode).toBe(0); return r.stdout.toString(); };
sh('git init -q main && cd main && echo a > a && git add a && git commit -qm one && git branch feat && git worktree add -q ../wt feat', d);
sh('echo b > b && git add b && git commit -qm two && echo u > u && echo ignored > ig && echo ig > .gitignore', join(d, 'wt'));
expect(readFileSync(join(d, 'wt/.git'), 'utf8')).toStartWith('gitdir:');
sh(`bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack . > ../pack.tgz && mkdir ../vm && tar -xzf ../pack.tgz -C ../vm`, join(d, 'wt'));
expect(existsSync(join(d, 'vm/.git/HEAD'))).toBe(true);
expect(lstatSync(join(d, 'vm/.git')).isDirectory()).toBe(true);
expect(existsSync(join(d, 'vm/ig'))).toBe(false);
expect(sh('git rev-parse --abbrev-ref HEAD && git status --short && git log --oneline | wc -l', join(d, 'vm')).replace(/\s+/g, ' ').trim()).toBe('feat ?? .gitignore ?? u 2');
const wtree = (cwd: string) => sh(`bash ${JSON.stringify(join(ROOT, 'bin/gstack-wtree'))}`, cwd).trim();
expect(wtree(join(d, 'vm'))).toBe(wtree(join(d, 'wt')));
} finally { rmSync(d, { recursive: true, force: true }); }
}, 60_000);
test('pack snapshots a plain checkout without reading its live .git: remotes resolve, a detached HEAD gets a branch, and git running beside it cannot fail the tar', () => {
const d = mkdtempSync(join(tmpdir(), 'ubi-pack-live-'));
try {
const sh = (cmd: string, cwd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 60_000, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } }); expect(r.exitCode, r.stderr.toString()).toBe(0); return r.stdout.toString(); };
sh('git init -q --bare origin.git && git clone -q origin.git src 2>/dev/null && cd src && echo a > a && git add a && git commit -qm one && git push -q origin HEAD:main && echo b > b && git add b && git commit -qm two && git push -q origin HEAD:main && git checkout -q --detach && echo c > c && echo s > staged && git add staged && echo i > inta && git add -N inta', d);
const src = join(d, 'src');
const pack = `bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack .`;
// Twenty seconds of index.lock, worktree and ref churn in the checkout while pack runs three times.
const churn = Bun.spawn(['bash', '-c', 'end=$((SECONDS+20)); while [ $SECONDS -lt $end ]; do git update-index -q --refresh; git worktree add -q ../wt HEAD >/dev/null 2>&1; git worktree remove --force ../wt >/dev/null 2>&1; git update-ref refs/heads/churn HEAD; git update-ref -d refs/heads/churn; done'], { cwd: src, stdout: 'ignore', stderr: 'ignore' });
try {
for (const i of [1, 2, 3]) sh(`${pack} > ../pack-${i}.tgz`, src);
} finally { churn.kill(); }
sh('mkdir vm && tar -xzf pack-3.tgz -C vm', d);
const vm = join(d, 'vm');
expect(lstatSync(join(vm, '.git')).isDirectory()).toBe(true);
const head = sh('git rev-parse HEAD', src).trim();
expect(sh('git rev-parse HEAD && git rev-parse --abbrev-ref HEAD && git rev-parse origin/main && git config --get remote.origin.url && git status --short', vm).split('\n').filter(Boolean))
.toEqual([head, `detached-${head.slice(0, 12)}`, head, join(d, 'origin.git'), ' A inta', 'A staged', '?? c']);
// The staged blob and the intent-to-add entry's empty blob are behind no ref; the snapshot carries them, so the VM can read and write-tree them.
expect(sh('git fsck --no-progress 2>&1; git diff --cached --name-only', vm).trim()).toBe('staged');
const wtree = (cwd: string) => sh(`bash ${JSON.stringify(join(ROOT, 'bin/gstack-wtree'))}`, cwd).trim();
expect(wtree(vm)).toBe(wtree(src));
// The snapshot carries no hook path or other local config from the source checkout.
expect(sh('git config --local --list', vm)).not.toContain('hooksPath');
// A failed pack leaves no object copy behind in TMPDIR, and a branch's upstream rides along with its remote.
sh('mkdir ../tmpd && git checkout -q -b topic && git branch -q --set-upstream-to=origin/main topic', src);
const broken = Bun.spawnSync(['bash', '-c', `rm -f .git/index && TMPDIR=../tmpd ${pack} > ../pack-broken.tgz; echo rc=$?; ls ../tmpd`], { cwd: src, timeout: 60_000 });
expect(broken.stderr.toString()).toContain('.git/index');
expect(broken.stdout.toString().trim()).toBe('rc=1');
// A gitlink in the index (a submodule commit this object store lacks) is not packed, and a valueless
// remote key (`[remote "origin"] mirror`) replays as true.
sh('git reset -q && git update-index --add --cacheinfo 160000,1111111111111111111111111111111111111111,sub && printf \'[remote "origin"]\\n\\tmirror\\n\' >> .git/config', src);
sh('mkdir vm2 && ' + pack.replace('pack .', 'pack src') + ' | tar -xzf - -C vm2', d);
expect(sh('git rev-parse --abbrev-ref @{u} && git config --bool --get remote.origin.mirror && git ls-files -s sub | cut -c1-6', join(d, 'vm2')).split('\n').filter(Boolean)).toEqual(['origin/main', 'true', '160000']);
} finally { rmSync(d, { recursive: true, force: true }); }
}, 90_000);
test('up records the VM in the scratch file before the create request is sent', () => {
const runner = read('scripts/ubicloud/ubi-runner.sh');
const create = runner.indexOf('cli "${args[@]}" >/dev/null 2>"$dir/create.err"');
expect(create).toBeGreaterThan(0);
expect(runner.indexOf('record_vm "$name" "$location"')).toBeLessThan(create);
expect(runner).toContain('$HOME/.capy/work/ubi-runner/vms.tsv');
});
});
describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => {
/**
* Fake Ubicloud API that can create: create adds the name to the listed file
* (or answers the quota refusal in refuse with HTTP 400), show is running at
* 192.0.2.1 while listed, destroy unlists. The fake VM is the directory vm/:
* the fake ssh records every remote command in ssh.log, keeps copies of
* ~/.ubirun-env and ~/.ubirun-setup.sh under the fixture root, serves pulls
* from remote-fs/, and runs every other command there for real (HOME=vm/,
* vmbin/ first on PATH), so uploads unpack, detached phases write their pid,
* output and exit files, tail --pid follows them and pkill ends them. The stub
* make in vmbin/ echoes `ran: make ARGS`, or runs command-mode or
* make-<target>.sh from the fixture root. No network, no VM.
*/
function fakeVm(opts: { listed?: string; refuse?: string } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ubi-run-'));
const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log'), sshLog = join(root, 'ssh.log'), record = join(root, 'vms.tsv');
const vm = join(root, 'vm'), vmbin = join(root, 'vmbin'), srcDir = join(root, 'src'), checkpoints = join(root, 'checkpoints');
mkdirSync(vm); mkdirSync(vmbin); mkdirSync(srcDir);
writeFileSync(join(srcDir, 'README'), 'fixture checkout\n');
mkdirSync(bin); mkdirSync(state, { recursive: true });
writeFileSync(join(root, 'listed'), opts.listed ?? '');
writeFileSync(join(bin, 'curl'), '#!/usr/bin/env bash\n' +
'out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\n' +
'body=$(cat); echo "$body" >> ' + JSON.stringify(log) + '\n' +
'listed=' + JSON.stringify(join(root, 'listed')) + '\n' +
'loc=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"\\([^/"]*\\)\\/[^"]*".*/\\1/p\')\n' +
'name=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"[^/"]*\\/\\([^"]*\\)".*/\\1/p\')\n' +
'case "$body" in\n' +
' *\'"create"\'*) if [ -n ' + JSON.stringify(opts.refuse ?? '') + ' ]; then printf \'%s\' ' + JSON.stringify(opts.refuse ?? '') + ' > "$out"; printf 400; else echo "$loc $name" >> "$listed"; : > "$out"; printf 200; fi ;;\n' +
' *destroy*) : > "$out"; if [ -f "$listed.destroy-fails" ]; then printf 500; else grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; printf 200; fi ;;\n' +
' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" > "$out"; printf 200; fi ;;\n' +
' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' +
' *) : > "$out"; printf 200 ;;\n' +
'esac\n');
writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\n' +
'root=' + JSON.stringify(root) + '; vm="$root/vm"\n' +
'cmd=${*: -1}\n' +
'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' +
'export HOME="$vm" PATH="$root/vmbin:$PATH"\n' +
'cd "$vm" || exit 255\n' +
'case "$cmd" in\n' +
' *pkill*) echo "$cmd" >> "$root/cancel.log"; exec bash -c "$cmd" ;;\n' +
' *captured*) cat > "$root/captured" ;;\n' +
' *"cat > ~/.ubirun-env"*) tee "$root/ubirun-env" > "$vm/.ubirun-env" ;;\n' +
' *"cat > ~/.ubirun-setup.sh"*) tee "$root/ubirun-setup.sh" > "$vm/.ubirun-setup.sh" ;;\n' +
' *"tar -czf -"*) base=${cmd##* }; tar -czf - -C "$root/remote-fs" "$base" ;;\n' +
' *"ls -d -- "*) : ;;\n' +
' *) exec bash -c "$cmd" ;;\n' +
'esac\n');
writeFileSync(join(vmbin, 'make'), '#!/usr/bin/env bash\n' +
'root=' + JSON.stringify(root) + '\n' +
'if [ -f "$root/command-mode" ]; then exec bash -c "$(cat "$root/command-mode")"; fi\n' +
'echo "ran: make $*"\n' +
'[ ! -f "$root/make-$1.sh" ] || exec bash "$root/make-$1.sh"\n');
chmodSync(join(bin, 'curl'), 0o755); chmodSync(join(bin, 'ssh'), 0o755); chmodSync(join(vmbin, 'make'), 0o755);
const env: Record<string, string> = { ...(process.env as Record<string, string>), PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state, UBI_POLL_SECONDS: '1', UBI_VM_RECORD: record, UBI_OWNER: 'thrd1', UBI_HEARTBEAT: '0', UBI_CHECKPOINT_DIR: checkpoints };
const run = (args: string[], extra: Record<string, string> = {}, stdin?: string) =>
Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: stdin === undefined ? undefined : Buffer.from(stdin), timeout: 90_000 });
const spawn = (args: string[], extra: Record<string, string> = {}) =>
Bun.spawn(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout: 120_000 });
return {
root, state, record, run, spawn, vm, src: srcDir, checkpoints,
calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : ''),
ssh: () => (existsSync(sshLog) ? readFileSync(sshLog, 'utf8') : ''),
listed: () => readFileSync(join(root, 'listed'), 'utf8'),
};
}
const src = (f: { root: string }, cfg: unknown) => {
const dir = join(f.root, 'repo');
mkdirSync(join(dir, '.gstack'), { recursive: true });
mkdirSync(join(dir, 'scripts'), { recursive: true });
writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot\n');
writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg));
return dir;
};
const run = (f: ReturnType<typeof fakeVm>, args: string[], extra: Record<string, string> = {}, stdin?: string) => f.run(args, extra, stdin);
test('run: a hung setup is killed by the watchdog, exits 124, never reaches the command, and the VM is destroyed', () => {
const f = fakeVm();
try {
const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo "setup start"; sleep 20\n');
const r = run(f, ['run', '--src', f.src, '--setup', setup, '--', 'make test'], { UBI_IDLE_TIMEOUT: '2' });
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(124);
expect(err).toMatch(/watchdog: setup printed nothing for \d+s \(UBI_IDLE_TIMEOUT=2\)/);
expect(err).toContain('setup failed (exit 124)');
expect(f.ssh()).not.toContain('bash -lc');
expect(err).toContain('setup start');
// The setup runs detached in its own session: its leader's pid, both streams and the exit status are files on the VM.
expect(f.ssh()).toMatch(/setsid -f bash -c 'echo \$\$ >~\/\.ubirun-setup\.pid\.tmp && mv ~\/\.ubirun-setup\.pid\.tmp ~\/\.ubirun-setup\.pid; \( cd work\/[^ ]+ && \. ~\/\.ubirun-env && bash -l ~\/\.ubirun-setup\.sh \) >~\/\.ubirun-setup\.out 2>~\/\.ubirun-setup\.err <\/dev\/null; echo \$\? >~\/\.ubirun-setup\.exit\.tmp && mv ~\/\.ubirun-setup\.exit\.tmp ~\/\.ubirun-setup\.exit' <\/dev\/null/m);
expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/sid=\$\(cat ~\/\.ubirun-setup\.pid 2>\/dev\/null\); rm -f ~\/\.ubirun-setup\.pid; \[ -z "\$sid" \] \|\| \{ pkill -TERM -s "\$sid"; sleep 2; pkill -KILL -s "\$sid"; \}/);
expect(existsSync(join(f.vm, '.ubirun-setup.exit'))).toBe(false);
expect(f.calls()).toContain('destroy');
expect(err).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3/);
expect(f.listed()).toBe('');
expect(readFileSync(f.record, 'utf8')).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: UBI_RUN_TIMEOUT kills a command that keeps printing, with exit 124 and its reason', () => {
const f = fakeVm();
try {
writeFileSync(join(f.root, 'command-mode'), 'for i in 1 2 3 4 5 6 7 8 9 10; do echo "tick $i"; sleep 1; done');
const r = run(f, ['run', '--src', f.src, '--', 'make test'], { UBI_RUN_TIMEOUT: '3' });
expect(r.exitCode).toBe(124);
expect(r.stderr.toString()).toMatch(/watchdog: command ran \d+s \(UBI_RUN_TIMEOUT=3\); killing it/);
expect(r.stderr.toString()).toContain('command exited 124 after');
expect(f.calls()).toContain('destroy');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: ends with UBICLOUD: left=0 for its own VM; a same-owner sibling is counted as information and does not fail the run', () => {
const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n' });
try {
const leftFile = join(f.root, 'left');
const r = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_LEFT_FILE: leftFile });
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(0);
expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/);
expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n');
// Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh)
expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/);
const parser = read('scripts/ubicloud/pregate-remote.sh').match(/left=\$\(sed -n '([^']+)' "\$log\.left"/)![1];
expect(Bun.spawnSync(['sed', '-n', parser, leftFile], { timeout: 30_000 }).stdout.toString()).toBe('0\n');
// Value: protects=run --keep reports its live VM as UBICLOUD: left=1 ... kept=1 and never a clean left=0; fails_when=the kept line is dropped or --keep still prints the teardown line; why_new=no test runs run --keep; seam=none; no_claim=real VM lifetime (fake API and ssh)
const kept = f.run(['run', '--src', f.src, '--keep', '--', 'true'], { UBI_REAP: '0' });
const keptErr = kept.stderr.toString();
expect(kept.exitCode, keptErr).toBe(0);
expect(keptErr).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 kept=1\n/);
expect(keptErr).not.toContain('left=0');
expect(f.listed()).toMatch(/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3\n/);
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
test('run: a passing command whose teardown fails exits 1 with UBICLOUD: left=1 and the down command; an unreadable VM list fails closed', () => {
const f = fakeVm();
try {
writeFileSync(join(f.root, 'listed.destroy-fails'), '');
const leaked = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' });
const err = leaked.stderr.toString();
expect(leaked.exitCode, err).toBe(1);
expect(err).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=(ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3) others=0\n[^]*VM cleanup not confirmed for \1 \(left=1\); destroy it with: \S+ down \1/);
expect(f.listed()).toContain('ubirun-thrd1-');
// Value: protects=a failing command whose VM also leaks keeps its own exit status, not 1; fails_when=run_teardown exits 1 whenever left is not 0; why_new=the leak case above only runs a passing command; seam=none; no_claim=real destroy failure modes (fake API returns 500)
writeFileSync(join(f.root, 'command-mode'), 'exit 7');
const failedLeak = f.run(['run', '--src', f.src, '--', 'make test'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' });
expect(failedLeak.exitCode, failedLeak.stderr.toString()).toBe(7);
expect(failedLeak.stderr.toString()).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/);
const blind = fakeVm();
try {
writeFileSync(join(blind.root, 'bin/curl.real'), readFileSync(join(blind.root, 'bin/curl')));
chmodSync(join(blind.root, 'bin/curl.real'), 0o755);
writeFileSync(join(blind.root, 'bin/curl'), '#!/usr/bin/env bash\nif [ -f ' + JSON.stringify(join(blind.root, 'armed')) + ' ]; then touch ' + JSON.stringify(join(blind.root, 'listed.list-fails')) + '; fi\nexec ' + JSON.stringify(join(blind.root, 'bin/curl.real')) + ' "$@"\n');
chmodSync(join(blind.root, 'bin/curl'), 0o755);
writeFileSync(join(blind.root, 'command-mode'), 'touch ' + JSON.stringify(join(blind.root, 'armed')));
const r = blind.run(['run', '--src', blind.src, '--', 'make test'], { UBI_REAP: '0' });
expect(r.exitCode, r.stderr.toString()).toBe(1);
expect(r.stderr.toString()).toMatch(/UBICLOUD: left=unknown owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=unknown\n/);
} finally { rmSync(blind.root, { recursive: true, force: true }); }
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => {
const f = fakeVm();
try {
const checkout = join(f.root, 'ubi-run-O1x2y3');
mkdirSync(checkout);
const r = run(f, ['run', '--src', checkout, '--', 'make test']);
expect(r.exitCode, r.stderr.toString()).toBe(0);
expect(r.stdout.toString()).toContain('ran: ');
expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => {
const f = fakeVm();
try {
const ok = run(f, ['run', '--src', f.src, '--pass', 'UBI_TEST_TOKEN', '--env', 'CI=1', '--', 'make test'], { UBI_TEST_TOKEN: 'sekret value' });
expect(ok.exitCode).toBe(0);
expect(ok.stderr.toString()).toContain('command exited 0 after');
const envFile = readFileSync(join(f.root, 'ubirun-env'), 'utf8');
expect(envFile).toContain('export UBI_TEST_TOKEN=sekret\\ value');
expect(envFile).toContain('export CI=1');
expect(envFile).not.toContain('UBICLOUD');
expect(ok.stdout.toString() + ok.stderr.toString()).not.toContain('sekret');
for (const bad of ['UBICLOUD_API_KEY', 'UBICLOUD_API_TOKEN', 'X;Y', 'HOME/..']) {
const before = f.calls();
const r = run(f, ['run', '--src', f.src, '--pass', bad, '--', 'true']);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toMatch(bad.startsWith('UBICLOUD_') ? /never forwards its own Ubicloud credentials/ : /not a variable name/);
expect(f.calls()).toBe(before);
}
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('ssh NAME COMMAND hands the caller’s stdin to the remote command', () => {
const f = fakeVm({ listed: 'eu-central-h1 vm-a\n' });
try {
mkdirSync(join(f.state, 'vm-a'), { recursive: true });
writeFileSync(join(f.state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nSIZE=standard-16\nIP=192.0.2.1\nCREATED=yes\nCREATE_AT=0\nCREATE_PID=1\n');
const r = run(f, ['ssh', 'vm-a', 'cat > captured'], { UBI_IDLE_TIMEOUT: '0' }, 'payload line\n');
expect(r.exitCode).toBe(0);
expect(readFileSync(join(f.root, 'captured'), 'utf8')).toBe('payload line\n');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
test('verify forwards a configured pass name only when the command line grants it, and never UBICLOUD_*', () => {
const f = fakeVm();
try {
const dir = src(f, { setup: 'scripts/boot.sh', command: 'make verify', pass: ['UBI_TEST_TOKEN'] });
const refused = run(f, ['verify', '--src', dir], { UBI_TEST_TOKEN: 'x' });
expect(refused.exitCode).toBe(1);
expect(refused.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN; grant each on this command line with --pass NAME, or every name it lists with --trust-config');
expect(f.calls()).toBe('');
const granted = run(f, ['verify', '--src', dir, '--pass', 'UBI_TEST_TOKEN'], { UBI_TEST_TOKEN: 'granted value' });
expect(granted.exitCode).toBe(0);
expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=granted\\ value');
expect(f.ssh()).toContain('bash -lc make\\ verify');
rmSync(join(f.root, 'ubirun-env'));
const trusted = run(f, ['verify', '--src', dir, '--trust-config'], { UBI_TEST_TOKEN: 'trusted value' });
expect(trusted.exitCode).toBe(0);
expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=trusted\\ value');
rmSync(join(f.root, 'ubirun-env'));
const asValue = run(f, ['verify', '--src', dir, '--dest', '--trust-config', '--keep', '--dest', 'work/safe'], { UBI_TEST_TOKEN: 'x' });
expect(asValue.exitCode).toBe(1);
expect(asValue.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN');
expect(existsSync(join(f.root, 'ubirun-env'))).toBe(false);
expect(run(f, ['verify', '--src', dir, '--pass']).stderr.toString()).toContain('verify: --pass needs a value');
expect(run(f, ['verify', '--src', dir, '--bogus']).stderr.toString()).toContain('verify: unknown option --bogus');
writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ command: 'make verify', pass: ['UBICLOUD_API_KEY'] }));
const before = f.calls();
const key = run(f, ['verify', '--src', dir, '--trust-config']);
expect(key.exitCode).toBe(1);
expect(key.stderr.toString()).toContain('never forwards its own Ubicloud credentials');
expect(f.calls()).toBe(before);
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('verify keeps a configured setup script and pull destination inside --src: absolute, ~ and .. paths are refused before any API call', () => {
const f = fakeVm();
try {
const dir = src(f, {});
const outside = join(f.root, 'outside.sh'); writeFileSync(outside, 'echo outside\n');
const cases: Array<[unknown, RegExp]> = [
[{ setup: outside, command: 'x' }, /setup '[^']*outside\.sh' must be a relative path inside the checkout/],
[{ setup: '../outside.sh', command: 'x' }, /setup '\.\.\/outside\.sh' must be a relative path inside the checkout/],
[{ setup: 'scripts/../../outside.sh', command: 'x' }, /must be a relative path inside the checkout/],
[{ setup: '~/outside.sh', command: 'x' }, /must be a relative path inside the checkout/],
[{ command: 'x', pull: ['work/logs:/etc/cron.d'] }, /pull destination '\/etc\/cron\.d' must be a relative path inside the checkout/],
[{ command: 'x', pull: ['work/logs:../stolen'] }, /pull destination '\.\.\/stolen' must be a relative path inside the checkout/],
[{ command: 'x', pull: ['no-colon'] }, /pull entry 'no-colon' must be REMOTE_GLOB:LOCAL_DIR/],
];
for (const [cfg, want] of cases) {
writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg));
const r = run(f, ['verify', '--src', dir]);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toMatch(want);
}
expect(f.calls()).toBe('');
writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ setup: 'scripts/boot.sh', command: 'make verify', pull: ['work/repo/logs:.context/ubicloud'] }));
const ok = run(f, ['verify', '--src', dir]);
expect(ok.exitCode).toBe(0);
expect(readFileSync(join(f.root, 'ubirun-setup.sh'), 'utf8')).toBe('echo boot\n');
expect(f.ssh()).toContain('ls -d -- logs');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('verify rechecks every pull destination at pull time: a symlink an earlier pull planted cannot redirect a later pull outside --src', () => {
const f = fakeVm();
try {
const dir = src(f, { command: 'make verify', pull: ['work/repo/escape:.', 'work/repo/artifacts:escape/new/sub'] });
const victim = join(f.root, 'victim'); mkdirSync(victim);
mkdirSync(join(f.root, 'remote-fs/artifacts'), { recursive: true });
writeFileSync(join(f.root, 'remote-fs/artifacts/authorized_keys'), 'ssh-ed25519 AAAA attacker\n');
symlinkSync(victim, join(f.root, 'remote-fs/escape'));
const r = run(f, ['verify', '--src', dir]);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toMatch(/pull refused: .*\/repo\/escape\/new\/sub resolves outside .*\/repo/);
expect(lstatSync(join(dir, 'escape')).isSymbolicLink()).toBe(true);
expect(readdirSync(victim)).toEqual([]);
expect(f.calls()).toContain('destroy');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('quota: a refusal caches the maximum, the cache drives the preflight for a day and is dropped after; UBI_QUOTA_WAIT waits before refusing', () => {
const refuse = fakeVm({ refuse: 'Validation failed for following fields: Requested vCPU count: 16, currently used vCPU count: 32, maximum allowed vCPU count: 32' });
try {
const r = run(refuse, ['up']);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toContain('create failed: vCPU quota exhausted');
expect(readFileSync(join(refuse.state, 'quota-max'), 'utf8').trim()).toBe('32');
expect(refuse.listed()).toBe('');
} finally { rmSync(refuse.root, { recursive: true, force: true }); }
const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' });
try {
writeFileSync(join(f.state, 'quota-max'), '31\n');
const cached = run(f, ['up']);
expect(cached.exitCode).toBe(1);
expect(cached.stderr.toString()).toMatch(/already use 16 of 31 \(ceiling from the last refusal, cached in .*quota-max;/);
expect(f.calls()).not.toContain('"create"');
const started = Date.now();
const waited = run(f, ['up'], { UBI_QUOTA_WAIT: '3' });
expect(waited.exitCode).toBe(1);
expect(waited.stderr.toString()).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 3s more for room/);
expect(waited.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted');
expect(Date.now() - started).toBeGreaterThanOrEqual(3000);
expect(f.calls()).not.toContain('"create"');
utimesSync(join(f.state, 'quota-max'), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000));
const stale = run(f, ['up']);
expect(stale.exitCode).toBe(0);
expect(stale.stderr.toString()).not.toContain('quota preflight refused');
expect(f.calls()).toContain('"create"');
expect(existsSync(join(f.state, 'quota-max'))).toBe(false);
const name = stale.stdout.toString().trim();
expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/);
expect(run(f, ['down', name]).exitCode).toBe(0);
expect(f.listed()).toBe('eu-central-h1 ubirun-peer-9999999999-cccc\n');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
// Value: protects=the quota wait budget counts seconds slept, not usage reads: a slow usage read keeps the printed budget, a wait above 30 s counts down in 30 s steps, and the wait releases when room appears; fails_when=the budget goes back to a wall-clock deadline taken before the first usage read (the first log line shrinks to 1s), the step cap or the decrement is lost, or room found mid-wait still refuses; why_new=the WAIT=3 case above passes under both the old deadline and the new countdown because its fake usage read is instant and it never sees a second step; seam=none (the fixture's bin/ is already first on PATH, so sleep and curl are stubbed there); no_claim=real elapsed time of the long wait (the 70 s case stubs sleep)
test('quota wait: a slow usage read does not shave the printed budget; a long wait counts down in 30s steps and releases when room appears', () => {
const slow = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' });
try {
writeFileSync(join(slow.state, 'quota-max'), '31\n');
const r = run(slow, ['up'], { UBI_QUOTA_WAIT: '2', FAKE_SHOW_DELAY: '1.2' });
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(1);
expect(err).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 2s more for room/);
expect(err).toContain('create refused before any request: vCPU quota exhausted');
expect(slow.calls()).not.toContain('"create"');
} finally { rmSync(slow.root, { recursive: true, force: true }); }
const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' });
try {
writeFileSync(join(f.state, 'quota-max'), '31\n');
const sleepLog = join(f.root, 'sleep.log');
writeFileSync(join(f.root, 'bin/sleep'), '#!/usr/bin/env bash\necho "$1" >> ' + JSON.stringify(sleepLog) + '\n[ "$(wc -l < ' + JSON.stringify(sleepLog) + ')" -ne 2 ] || : > ' + JSON.stringify(join(f.root, 'listed')) + '\n');
chmodSync(join(f.root, 'bin/sleep'), 0o755);
const r = run(f, ['up'], { UBI_QUOTA_WAIT: '70' });
const err = r.stderr.toString();
expect(r.exitCode, err).toBe(0);
expect(err).toMatch(/waiting up to 70s more for room/);
expect(err).toMatch(/waiting up to 40s more for room/);
expect(err).not.toContain('quota preflight refused');
expect(readFileSync(sleepLog, 'utf8').split('\n').slice(0, 2)).toEqual(['30', '30']);
expect(f.calls()).toContain('"create"');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => {
const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' });
try {
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n');
const destroyFails = run(f, ['reap']);
expect(destroyFails.exitCode).toBe(1);
expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1');
expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed');
writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n');
const listFails = run(f, ['reap']);
expect(listFails.exitCode).toBe(1);
expect(listFails.stderr.toString()).toContain("reap: could not list the project's VMs; nothing destroyed");
for (const ttl of ['0', '08', '1000', 'abc']) {
const r = run(f, ['up'], { UBI_TTL_HOURS: ttl });
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toContain('UBI_TTL_HOURS must be a whole number of hours from 1 to 999');
}
// Value: protects=an old UBI_TTL_HOURS=0 or bad UBI_LEGACY_HOURS never blocks commands that create nothing (down, list, owner); fails_when=the TTL checks move back to startup; why_new=the checks were global and blocked cleanup; seam=none; no_claim=real down against the API (owner stands in for every read-only command)
expect(run(f, ['owner'], { UBI_TTL_HOURS: '0', UBI_LEGACY_HOURS: '0' }).exitCode).toBe(0);
for (const legacy of ['0', '04', 'x']) {
const r = run(f, ['reap'], { UBI_LEGACY_HOURS: legacy });
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toContain('UBI_LEGACY_HOURS must be a whole number of hours from 1 to 9999');
}
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
// GSTA-158 P3: detached phases, reattach and tier checkpoints.
const until = async (cond: () => boolean, ms: number) => {
const deadline = Date.now() + ms;
while (!cond()) { if (Date.now() > deadline) throw new Error('condition not met in time'); await Bun.sleep(100); }
};
const vmDirs = (f: ReturnType<typeof fakeVm>) => (existsSync(f.state) ? readdirSync(f.state).filter(d => d.startsWith('ubirun-')) : []);
const phaseStarted = (f: ReturnType<typeof fakeVm>, phase: string) => vmDirs(f).some(d => existsSync(join(f.state, d, 'phase.' + phase + '.start')));
const creates = (f: ReturnType<typeof fakeVm>) => f.calls().split('\n').filter(l => l.includes('"create"')).length;
const killSession = async (f: ReturnType<typeof fakeVm>, phase: string) => {
const pidFile = join(f.vm, '.ubirun-' + phase + '.pid');
await until(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').trim() !== '', 10_000);
const pid = Number(readFileSync(pidFile, 'utf8').trim());
try { process.kill(-pid, 'SIGKILL'); } catch { /* already gone */ }
await until(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, 10_000);
};
const gitSrc = (f: ReturnType<typeof fakeVm>, cfg?: unknown) => {
const dir = cfg === undefined ? join(f.root, 'repo') : src(f, cfg);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'README'), 'fixture repo\n');
for (const args of [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
const r = Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: dir, timeout: 30_000 });
if (r.exitCode !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr.toString());
}
return dir;
};
const TIERS = { setup: 'scripts/boot.sh', tiers: [{ name: 't1', command: 'make t1' }, { name: 't2', command: 'make t2' }, { name: 't3', command: 'make t3' }] };
const bootCounts = (f: ReturnType<typeof fakeVm>, dir: string) => writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot >> ~/setup-count\n');
const verifyLine = (out: string) => out.split('\n').find(l => l.startsWith('UBI_VERIFY: ')) ?? '';
test('run: a SIGKILLed runner leaves its VM; the same command reattaches, skips the finished setup and reports the command\'s exit', async () => {
const f = fakeVm();
try {
const repo = gitSrc(f);
const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo setup >> ~/setup-count\n');
writeFileSync(join(f.root, 'make-test.sh'), 'echo begin; sleep 4; echo end; exit 3\n');
const args = ['run', '--src', repo, '--setup', setup, '--', 'make test'];
const a = f.spawn(args);
await until(() => vmDirs(f).some(d => existsSync(join(f.state, d, 'command.out')) && readFileSync(join(f.state, d, 'command.out'), 'utf8').includes('begin')), 30_000);
process.kill(a.pid, 'SIGKILL');
await a.exited;
const name = vmDirs(f)[0]!;
expect(f.listed()).toContain(name);
const b = f.run(args);
const err = b.stderr.toString();
expect(b.exitCode, err).toBe(3);
expect(err).toMatch(new RegExp('ubi-runner: reattached to ' + name + ' created_at=\\d{10} key=[0-9a-f]{12}\n'));
expect(b.stdout.toString().split('\n').filter(l => l === 'begin' || l === 'end')).toEqual(['begin', 'end']);
expect(err).toMatch(/command exited 3 after \d+s/);
expect(creates(f)).toBe(1);
expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('setup\n');
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('run: a second live runner of the same command refuses with UBI_ATTACH_BUSY and creates nothing', async () => {
const f = fakeVm();
try {
const repo = gitSrc(f);
writeFileSync(join(f.root, 'make-test.sh'), 'sleep 4; echo done\n');
const args = ['run', '--src', repo, '--', 'make test'];
const a = f.spawn(args);
await until(() => phaseStarted(f, 'command'), 30_000);
const b = f.run(args);
expect(b.exitCode).toBe(1);
expect(b.stderr.toString()).toMatch(new RegExp('UBI_ATTACH_BUSY: vm=' + vmDirs(f)[0] + ' key=[0-9a-f]{12}: runner pid ' + a.pid + ' '));
expect(await a.exited).toBe(0);
expect(await new Response(a.stdout).text()).toContain('done');
expect(creates(f)).toBe(1);
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('run: HUP once the command is live detaches (UBI_DETACHED, exit 129, VM kept, no pass value on disk); a rerun reattaches and finishes', async () => {
const f = fakeVm();
try {
const repo = gitSrc(f);
writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3; echo finished\n');
const args = ['run', '--src', repo, '--pass', 'UBI_TEST_TOKEN', '--', 'make test'];
const a = f.spawn(args, { UBI_TEST_TOKEN: 'hup-secret-value' });
await until(() => phaseStarted(f, 'command'), 30_000);
await Bun.sleep(500);
process.kill(a.pid, 'SIGHUP');
expect(await a.exited).toBe(129);
const name = vmDirs(f)[0]!;
const err = await new Response(a.stderr).text();
expect(err).toMatch(new RegExp('UBI_DETACHED: vm=' + name + ' key=[0-9a-f]{12} reattach: rerun the same command'));
expect(f.listed()).toContain(name);
expect(f.calls()).not.toContain('destroy');
for (const file of readdirSync(join(f.state, name))) {
const p = join(f.state, name, file);
if (lstatSync(p).isFile()) expect(readFileSync(p, 'utf8')).not.toContain('hup-secret-value');
}
const b = f.run(args, { UBI_TEST_TOKEN: 'hup-secret-value' });
expect(b.exitCode, b.stderr.toString()).toBe(0);
expect(b.stderr.toString()).toContain('reattached to ' + name);
expect(b.stdout.toString()).toContain('finished');
expect(creates(f)).toBe(1);
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('run: INT and TERM during a live command still tear the VM down', async () => {
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
const f = fakeVm();
try {
const repo = gitSrc(f);
writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3\n');
const a = f.spawn(['run', '--src', repo, '--', 'make test']);
await until(() => phaseStarted(f, 'command'), 30_000);
await Bun.sleep(500);
process.kill(a.pid, sig);
expect(await a.exited).toBe(130);
expect(await new Response(a.stderr).text()).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-/);
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}
}, 90_000);
test('run: a remote session gone without an exit status exits 125 with UBI_ATTACH_LOST and the VM name', async () => {
const f = fakeVm();
try {
const repo = gitSrc(f);
writeFileSync(join(f.root, 'make-test.sh'), 'sleep 30\n');
const args = ['run', '--src', repo, '--', 'make test'];
const a = f.spawn(args);
await until(() => existsSync(join(f.vm, '.ubirun-command.pid')) && readFileSync(join(f.vm, '.ubirun-command.pid'), 'utf8').trim() !== '', 30_000);
process.kill(a.pid, 'SIGKILL');
await a.exited;
await killSession(f, 'command');
const name = vmDirs(f)[0]!;
const b = f.run(args);
expect(b.exitCode).toBe(125);
expect(b.stderr.toString()).toContain('UBI_ATTACH_LOST: vm=' + name + ' phase=command');
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
test('run: the run key covers tree, command, setup content, env, pass value, image, origin/HEAD and the runner itself; a pass value never appears in it', () => {
const f = fakeVm();
try {
const repo = gitSrc(f);
const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo one\n');
const runner = join(f.root, 'runner-copy.sh'); writeFileSync(runner, readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8'));
const key = (opts: { args?: string[]; env?: Record<string, string>; cmd?: string; script?: string } = {}) => {
const r = Bun.spawnSync(['bash', opts.script ?? join(DIR, 'ubi-runner.sh'), 'run', '--probe', '--src', repo, '--setup', setup, '--env', 'MODE=a', '--pass', 'UBI_TEST_TOKEN', ...(opts.args ?? []), '--', opts.cmd ?? 'make test'],
{ env: { ...process.env, PATH: join(f.root, 'bin') + ':' + process.env.PATH, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: f.state, UBI_OWNER: 'thrd1', UBI_TEST_TOKEN: 'v1', ...(opts.env ?? {}) }, timeout: 30_000 });
const out = r.stdout.toString() + r.stderr.toString();
expect(out).not.toContain('v1');
const m = /^UBI_ATTACH_INFO: none key=([0-9a-f]{12})$/m.exec(out);
if (!m) throw new Error(out);
return m[1]!;
};
const base = key();
expect(key()).toBe(base);
const variants: Record<string, string> = {};
variants.command = key({ cmd: 'make check' });
variants.env = key({ args: ['--env', 'MODE=b'] });
variants.pass = key({ env: { UBI_TEST_TOKEN: 'v2' } });
variants.image = key({ args: ['-b', 'ubuntu-jammy'] });
variants.size = key({ args: ['-s', 'standard-8'] });
writeFileSync(runner, readFileSync(runner, 'utf8') + '# edited\n');
variants.runner = key({ script: runner });
writeFileSync(setup, 'echo two\n');
variants.setup = key();
writeFileSync(setup, 'echo one\n');
expect(key()).toBe(base);
Bun.spawnSync(['git', 'update-ref', 'refs/remotes/origin/HEAD', 'HEAD'], { cwd: repo, timeout: 10_000 });
variants.base = key();
writeFileSync(join(repo, 'new-file.txt'), 'untracked source\n');
variants.tree = key();
for (const [what, k] of Object.entries(variants)) expect({ what, same: k === base }).toEqual({ what, same: false });
expect(new Set(Object.values(variants)).size).toBe(Object.keys(variants).length);
expect(f.calls()).not.toContain('"create"');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000);
test('verify tiers: config refuses command with tiers, bad and duplicate tier names, before any API call', () => {
const f = fakeVm();
try {
const cases: Array<[unknown, RegExp]> = [
[{ command: 'make', tiers: [{ name: 'a', command: 'x' }] }, /UBI_CONFIG_TIERS_AND_COMMAND/],
[{ tiers: [{ name: 'Unit', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name 'Unit' must match/],
[{ tiers: [{ name: '-x', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name '-x'/],
[{ tiers: [{ name: 'a'.repeat(33), command: 'x' }] }, /UBI_CONFIG_TIER_NAME/],
[{ tiers: [{ name: 'a.b', command: 'x' }] }, /UBI_CONFIG_TIER_NAME/],
[{ tiers: [{ name: 'a', command: 'x' }, { name: 'a', command: 'y' }] }, /UBI_CONFIG_TIER_NAME: tier name 'a' appears twice/],
[{ tiers: [{ name: 'a' }] }, /UBI_CONFIG_TIER_NAME: tier 'a' needs a command/],
[{ tiers: [] }, /UBI_CONFIG_TIER_NAME: tiers must be a non-empty list/],
];
for (const [cfg, want] of cases) {
const dir = src(f, cfg);
const r = run(f, ['verify', '--src', dir]);
expect(r.exitCode).toBe(1);
expect(r.stderr.toString()).toMatch(want);
}
expect(f.calls()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 30_000);
test('verify tiers: a run killed during tier 3 with its VM gone resumes on a new VM: setup reruns, t1 and t2 are reused, t3 reruns', async () => {
const f = fakeVm();
try {
const dir = gitSrc(f, TIERS);
bootCounts(f, dir);
Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 });
writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n');
const a = f.spawn(['verify', '--src', dir]);
await until(() => existsSync(join(f.root, 't3-started')), 60_000);
process.kill(a.pid, 'SIGKILL');
await a.exited;
await killSession(f, 'tier-t3');
const first = vmDirs(f)[0]!;
writeFileSync(join(f.root, 'listed'), '');
writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n');
const b = f.run(['verify', '--src', dir]);
const err = b.stderr.toString(), out = b.stdout.toString();
expect(b.exitCode, err).toBe(0);
expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse t1\.\d{10}\.\d+\.json \(\d+s\)\n/);
expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse t2\.\d{10}\.\d+\.json \(\d+s\)\n/);
expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t3 run: interrupted \(only a start record, t3\.\d{10}\.\d+\.json\)\n/);
expect(err).toContain('run record for ' + first + ': the VM is gone; starting a new one');
expect(err).not.toContain('reattached to');
expect(creates(f)).toBe(2);
expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('boot\nboot\n');
expect(out).not.toContain('ran: make t1');
expect(out).toContain('t3 ok');
expect(verifyLine(out)).toMatch(/^UBI_VERIFY: tree=[0-9a-f]{12} key=[0-9a-f]{12} tiers=3 passed=3 reused=t1,t2 failed=none prior_failures=none saved_s=\d+$/);
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 120_000);
test('verify tiers: a fail record blocks a rerun until --rerun-failed (then prior_failures); passes are reused unless stale, tampered, --no-resume or the tree changed', () => {
const f = fakeVm();
try {
const dir = gitSrc(f, TIERS);
writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 broke; exit 7\n');
const first = run(f, ['verify', '--src', dir]);
expect(first.exitCode, first.stderr.toString()).toBe(7);
expect(first.stdout.toString()).not.toContain('ran: make t3');
expect(verifyLine(first.stdout.toString())).toMatch(/ tiers=3 passed=1 reused=none failed=t2 prior_failures=none saved_s=0$/);
expect(creates(f)).toBe(1);
const blocked = run(f, ['verify', '--src', dir]);
expect(blocked.exitCode).toBe(7);
expect(blocked.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 fail t2\.\d{10}\.\d+\.json \(\d+s, exit 7\): not rerun without --rerun-failed/);
expect(verifyLine(blocked.stdout.toString())).toMatch(/ failed=t2 prior_failures=none /);
expect(creates(f)).toBe(1);
writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 fixed\n');
const rerun = run(f, ['verify', '--src', dir, '--rerun-failed']);
expect(rerun.exitCode, rerun.stderr.toString()).toBe(0);
expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /);
expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 run: prior fail t2\.\d{10}\.\d+\.json \(exit 7; --rerun-failed\)/);
expect(verifyLine(rerun.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1 failed=none prior_failures=t2:7 saved_s=\d+$/);
expect(creates(f)).toBe(2);
const all = run(f, ['verify', '--src', dir]);
expect(all.exitCode).toBe(0);
expect(verifyLine(all.stdout.toString())).toMatch(/ passed=3 reused=t1,t2,t3 failed=none prior_failures=none saved_s=\d+$/);
expect(all.stderr.toString()).toContain('every tier passed under this key; no VM needed');
expect(creates(f)).toBe(2);
const probe = (args: string[] = [], extra: Record<string, string> = {}) => run(f, ['verify', '--src', dir, '--probe', ...args], extra).stderr.toString();
expect(probe(['--no-resume'])).toMatch(/tier=t1 run: --no-resume\n[^]*tier=t2 run: --no-resume\n[^]*tier=t3 run: --no-resume\n/);
expect(probe([], { UBI_CHECKPOINT_MAX_AGE: '0' })).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json is \d+s old \(UBI_CHECKPOINT_MAX_AGE=0\)/);
const keyDir = join(f.checkpoints, readdirSync(f.checkpoints)[0]!);
const t1Log = readdirSync(keyDir).filter(n => n.startsWith('t1.') && n.endsWith('.log')).sort().at(-1)!;
writeFileSync(join(keyDir, t1Log), 'tampered\n');
const tampered = probe();
expect(tampered).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json: its log does not re-hash/);
expect(tampered).toMatch(/tier=t2 reuse /);
writeFileSync(join(dir, 'new-source.ts'), 'export {}\n');
const moved = probe();
for (const t of ['t1', 't2', 't3']) expect(moved).toContain('UBI_VERIFY_RESUME: tier=' + t + ' run: no record');
expect(creates(f)).toBe(2);
// Records are write-once JSON; every one carries its generation.
const gens = readdirSync(keyDir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(keyDir, n), 'utf8')).generation as number).sort((x, y) => x - y);
expect(gens).toEqual(gens.map((_, i) => i + 1));
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 120_000);
test('verify tiers: a reattach whose VM TTL no longer covers the run left plus UBI_REATTACH_MARGIN starts a fresh VM, destroys the old one and reuses the passed tiers', async () => {
const f = fakeVm();
try {
const dir = gitSrc(f, TIERS);
bootCounts(f, dir);
Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 });
writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n');
const env = { UBI_REAP: '0', UBI_TTL_HOURS: '1' };
const a = f.spawn(['verify', '--src', dir], env);
await until(() => existsSync(join(f.root, 't3-started')), 60_000);
process.kill(a.pid, 'SIGKILL');
await a.exited;
const first = vmDirs(f)[0]!;
expect(first).toMatch(/-t1$/);
const probe = (extra: Record<string, string>) => run(f, ['verify', '--src', dir, '--probe'], { ...env, ...extra }).stdout.toString();
expect(probe({})).toMatch(new RegExp('UBI_ATTACH_INFO: vm=' + first + ' created_at=\\d{10} '));
// A 1 h TTL leaves about 3600 s: t3's 1200 s run timeout plus a 2700 s margin does not fit.
const short = { UBI_RUN_TIMEOUT: '1200', UBI_REATTACH_MARGIN: '2700' };
expect(probe(short)).toMatch(new RegExp('UBI_ATTACH_INFO: none key=[0-9a-f]{12} ttl_short vm=' + first + ' ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\)'));
expect(creates(f)).toBe(1);
writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n');
const b = run(f, ['verify', '--src', dir], { ...env, ...short });
const err = b.stderr.toString();
expect(b.exitCode, err).toBe(0);
expect(err).toMatch(new RegExp('UBI_REATTACH_TTL_SHORT: vm=' + first + ' key=[0-9a-f]{12} ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\): starting a fresh VM \\(tier checkpoints carry over\\) and destroying ' + first));
expect(err).not.toContain('reattached to');
expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /);
expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse /);
expect(verifyLine(b.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1,t2 failed=none /);
expect(creates(f)).toBe(2);
expect(f.listed()).toBe('');
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 120_000);
test('verify tiers: a writer whose start record is no longer the newest publishes nothing (stale writer)', () => {
const py = /\nCKPT_PY='\n([^']*)'\n/.exec(readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8'))![1]!;
const root = mkdtempSync(join(tmpdir(), 'ubi-ckpt-'));
try {
const d = join(root, 'key');
const log = join(root, 'tier.log'); writeFileSync(log, 'ok\n');
const publish = (status: string, startgen: string, pid: string) => Bun.spawnSync(['python3', '-c', py, 'publish', d, 't1', status, 'vm', '100', '200', '0', status === 'start' ? '' : log, startgen, 'k', 'tree', pid], { timeout: 30_000 });
expect(publish('start', '0', '1').stdout.toString().trim()).toBe('1');
expect(publish('start', '0', '2').stdout.toString().trim()).toBe('2');
const stale = publish('pass', '1', '1');
expect(stale.exitCode).toBe(2);
expect(stale.stderr.toString()).toContain('stale writer: its start record is generation 1, the newest for t1 is 2');
expect(readdirSync(d).filter(n => n.endsWith('.json')).length).toBe(2);
expect(publish('pass', '2', '2').stdout.toString().trim()).toBe('3');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('verify tiers: a tier that edits the source gets no pass record (UBI_VERIFY_TREE_MOVED) and reruns next time', () => {
const f = fakeVm();
try {
const dir = gitSrc(f, { tiers: [{ name: 'mover', command: 'make mover' }, { name: 'clean', command: 'make clean' }] });
writeFileSync(join(f.root, 'make-mover.sh'), 'echo edited > moved.txt\n');
const r = run(f, ['verify', '--src', dir]);
expect(r.exitCode, r.stderr.toString()).toBe(0);
expect(r.stderr.toString()).toMatch(/UBI_VERIFY_TREE_MOVED: tier=mover before=[0-9a-f]{12} after=[0-9a-f]{12}: a tier must leave the source tree as it found it; no pass record/);
expect(verifyLine(r.stdout.toString())).toMatch(/ tiers=2 passed=2 reused=none failed=none /);
const next = run(f, ['verify', '--src', dir, '--probe']).stderr.toString();
expect(next).toMatch(/tier=mover run: interrupted \(only a start record/);
expect(next).toMatch(/tier=clean reuse /);
} finally { rmSync(f.root, { recursive: true, force: true }); }
}, 90_000);
});