From eb07dbb3afe090108a6824048e3dab452b4d891d Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:06:17 +0000 Subject: [PATCH] fix(ubicloud): pack and usage on macOS, empty projects (#3680, #3679, #3678) pack builds its archive in two tar passes plus gzip, which bsdtar and GNU tar both accept, with COPYFILE_DISABLE=1 so Apple's tar adds no ._* files. vm_rows skips the blank row Ubicloud returns for a project with no VMs. usage parenthesizes its ternary for the one-true-awk and exits 1 on an unreadable list or size; the quota preflight logs that and defers to the API. Co-authored-by: Jared <182125291+yolo-jared@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../gsta210-ubicloud-macos-empty-project.md | 4 + scripts/ubicloud/ubi-runner.sh | 39 ++++-- ...on-issue3680-ubicloud-bsd-userland.test.ts | 122 ++++++++++++++++++ test/ubicloud-runner.test.ts | 81 +++++++++++- 4 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 changelog.d/gsta210-ubicloud-macos-empty-project.md create mode 100644 test/regression-issue3680-ubicloud-bsd-userland.test.ts diff --git a/changelog.d/gsta210-ubicloud-macos-empty-project.md b/changelog.d/gsta210-ubicloud-macos-empty-project.md new file mode 100644 index 000000000..3946e2ee9 --- /dev/null +++ b/changelog.d/gsta210-ubicloud-macos-empty-project.md @@ -0,0 +1,4 @@ +### Fixed +- **`gstack-ubi pack` works on macOS again.** macOS's built-in tar read pack's options as file names, so `sync`, `run` and `verify` failed after the VM was already created, shipping an archive holding only `.git`. Pack now builds the archive in two steps that work with both macOS and GNU tar, and keeps macOS tar from adding hidden `._*` copies of files that carry extended attributes. (#3680, reported by Jared) +- **`gstack-ubi usage` works on macOS, and the vCPU quota check runs there again.** One line of the usage report was a syntax error for macOS's awk, which also switched off the quota check before every create. `usage` now exits 1 instead of printing a partial count when Ubicloud can't list the VMs or read a size, and the quota check says so in the log and leaves the decision to Ubicloud. (#3679, reported by Jared) +- **`reap` and `up` work on a Ubicloud project with no VMs.** An empty VM list was read as one VM named `-`, so `reap` and every `up` died with `invalid VM name '-'`. (#3678, reported by Jared) diff --git a/scripts/ubicloud/ubi-runner.sh b/scripts/ubicloud/ubi-runner.sh index 441eaec8d..707254790 100755 --- a/scripts/ubicloud/ubi-runner.sh +++ b/scripts/ubicloud/ubi-runner.sh @@ -740,6 +740,7 @@ run_tiers() { vm_rows() { local loc name owner created expires cli vm list -N -f location,name | while read -r loc name; do + [ -n "$name" ] || continue owner=. created="" expires=- if [[ "$name" =~ ^${PREFIX}-([a-z][a-z0-9]{0,11})-([0-9]{10})- ]]; then owner=${BASH_REMATCH[1]} created=${BASH_REMATCH[2]} @@ -804,21 +805,31 @@ cmd_list() { } # usage: VMs and vCPUs per owner, from each VM's size (standard-16 = 16 vCPUs). +# Exits 1 with no table when the VM list or any VM's size cannot be read, so a +# partial count is never taken for the project's usage. cmd_usage() { - local tmp loc name owner i=0 + local tmp rows loc name owner f i=0 unread=0 + rows=$(vm_rows) || { log "usage: could not list the project's VMs"; return 1; } tmp=$(mktemp -d) while read -r loc name owner _; do + [ -n "$name" ] || continue i=$(( i + 1 )) cli vm "$loc/$name" show 2>/dev/null | sed -n "s/^size: /$owner /p" >"$tmp/$i" & [ $(( i % 8 )) -ne 0 ] || wait - done < <(vm_rows) + done <<<"$rows" wait - cat "$tmp"/* 2>/dev/null | awk -v me="$OWNER" ' + for f in "$tmp"/*; do [ ! -e "$f" ] || [ -s "$f" ] || unread=$(( unread + 1 )); done + if [ "$unread" -gt 0 ]; then + rm -rf "$tmp" + log "usage: could not read the size of $unread of $i VMs" + return 1 + fi + { [ "$i" -eq 0 ] || cat "$tmp"/*; } | awk -v me="$OWNER" ' { n = $2; sub(/^.*-/, "", n); o = $1 == "-" ? "(untagged)" : $1 == "." ? "(other)" : $1 vms[o]++; cpu[o] += n; total += n; count++ } END { printf "%-14s %4s %6s\n", "OWNER", "VMS", "VCPUS" - for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], o == me ? " (you)" : "" | "sort -k3,3nr" + for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], (o == me ? " (you)" : "") | "sort -k3,3nr" close("sort -k3,3nr") printf "%-14s %4d %6d\n", "total", count, total }' @@ -881,8 +892,10 @@ quota_preflight() { # between them: a slow first usage read must not shave the printed budget. remaining=$QUOTA_WAIT while :; do - used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') - [[ "$used" =~ ^[0-9]+$ ]] || return 0 + if ! used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') || ! [[ "$used" =~ ^[0-9]+$ ]]; then + log "quota preflight: could not read the project's vCPU usage; the API's own answer decides" + return 0 + fi [ $(( used + need )) -gt "$max" ] || return 0 if [ "$remaining" -le 0 ]; then log "quota preflight refused $location/$name ($size): it needs $need vCPUs and the project's VMs already use $used of $max (ceiling from $source; other usage such as managed runners counts on top)" @@ -1074,7 +1087,12 @@ cmd_down() { # pointer into the main repository, useless on the VM; the same snapshot # replaces it, on the worktree's own branch and index. Not carried: submodule # repositories (.git/modules) and a partial clone's promisor (the fetch pulls -# every missing blob first); gstack has neither. +# every missing blob first); gstack has neither. The archive is built in two +# passes inside the snapshot directory because macOS's bsdtar stops reading +# options at the first operand and resolves -T names after a -C, so no single +# tar command line works on both it and GNU tar (#3680). COPYFILE_DISABLE=1 +# keeps Apple's tar from adding ._* AppleDouble entries for files with +# extended attributes, which the VM would see as untracked files. cmd_pack() { local src tmp gitdir branch head entry src=$(cd "${1:-.}" && pwd) @@ -1118,15 +1136,18 @@ cmd_pack() { fi ( cd "$src" + export COPYFILE_DISABLE=1 git ls-files -z -co --exclude-standard \ | while IFS= read -r -d '' f; do if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi done \ - | tar --null -T - -c -C "$tmp" .git -zf - + | tar --null -T - -cf "$tmp/files.tar" \ + && tar -rf "$tmp/files.tar" -C "$tmp" .git \ + && gzip -c "$tmp/files.tar" ) rm -rf "$tmp"; trap - EXIT else - tar -C "$src" -czf - . + COPYFILE_DISABLE=1 tar -C "$src" -czf - . fi } diff --git a/test/regression-issue3680-ubicloud-bsd-userland.test.ts b/test/regression-issue3680-ubicloud-bsd-userland.test.ts new file mode 100644 index 000000000..b8039f529 --- /dev/null +++ b/test/regression-issue3680-ubicloud-bsd-userland.test.ts @@ -0,0 +1,122 @@ +/** + * Regression tests for #3680 and #3679: scripts/ubicloud/ubi-runner.sh on the + * BSD userland macOS ships. + * + * #3680 pack built its archive with `tar --null -T - -c -C "$tmp" .git -zf -`. + * bsdtar stops reading options at the first operand (so -zf and - were + * file names) and resolves -T names after the -C (so the work-tree + * files were looked up in the temp dir): exit 1 and an archive holding + * only .git, after the VM was already created. + * #3679 usage's END block printed `o == me ? " (you)" : ""` unparenthesized + * inside a printf list, a syntax error in the one-true-awk, so usage + * exited 2 and the vCPU quota preflight read no total and let every + * create through. + * + * Both need the real tools: macOS's /usr/bin/tar and /usr/bin/awk, or on Linux + * `bsdtar` (libarchive-tools) and `original-awk`. Where a tool is missing its + * cases are skipped, and the skip names the package. No network, no VM. + */ +import { describe, expect, test } from 'bun:test'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dir, '..'); +const RUNNER = join(ROOT, 'scripts/ubicloud/ubi-runner.sh'); +const darwin = process.platform === 'darwin'; +const bsdTar = darwin ? '/usr/bin/tar' : Bun.which('bsdtar'); +const bwkAwk = darwin ? '/usr/bin/awk' : Bun.which('original-awk'); + +/** A directory to put first on PATH whose `tar` or `awk` is the BSD one. */ +function bsdPath(root: string, tools: { tar?: string | null; awk?: string | null }) { + const bin = join(root, 'bsd-bin'); + mkdirSync(bin, { recursive: true }); + if (tools.tar) symlinkSync(tools.tar, join(bin, 'tar')); + if (tools.awk) symlinkSync(tools.awk, join(bin, 'awk')); + return `${bin}:${process.env.PATH}`; +} + +const gitEnv = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + +describe(`ubi-runner pack under bsdtar (#3680)${bsdTar ? '' : ': skipped, no bsdtar (install libarchive-tools)'}`, () => { + // Value: protects=pack writes a whole checkout under bsdtar (macOS's default tar), not an archive of .git alone; fails_when=pack passes tar options after an operand or resolves its -T list after a -C again; why_new=test/ubicloud-runner.test.ts runs pack only under GNU tar on Linux, so #3680 shipped green; seam=PATH (tar resolves to bsdtar); no_claim=the VM side's tar -xzf (GNU tar on Ubuntu, exercised by ubicloud-runner.test.ts) + test.skipIf(!bsdTar)('pack ships tracked, staged, untracked, symlinked, spaced and option-like names, a linked worktree and an empty file list', () => { + const d = mkdtempSync(join(tmpdir(), 'ubi-bsd-pack-')); + try { + const PATH = bsdPath(d, { tar: bsdTar }); + const sh = (cmd: string, cwd: string) => { + const r = Bun.spawnSync(['bash', '-c', cmd], { cwd, timeout: 30_000, env: { ...process.env, ...gitEnv, PATH } }); + expect(r.exitCode, `${cmd}\n${r.stderr.toString()}`).toBe(0); + return r.stdout.toString(); + }; + expect(sh('tar --version', d)).toContain('bsdtar'); + const pack = (src: string, out: string) => sh(`bash ${JSON.stringify(RUNNER)} pack ${JSON.stringify(src)} > ${JSON.stringify(out)}`, d); + const unpack = (tgz: string, dir: string) => { mkdirSync(join(d, dir)); sh(`tar -xzf ${JSON.stringify(tgz)} -C ${JSON.stringify(dir)}`, d); return join(d, dir); }; + const status = (cwd: string) => sh('git status --short', cwd).split('\n').filter(Boolean).sort(); + + sh('git init -q plain && cd plain && echo a > a && echo b > gone && echo s > "with space" && ln -s a link && echo o > ./-C && echo o > ./--files-from=a && git add -A && git commit -qm one && rm gone && echo st > staged && git add staged && echo u > untracked && echo x > "untracked space"', d); + pack('plain', 'plain.tgz'); + const listing = sh('tar -tzf plain.tgz', d).split('\n'); + expect(listing.some(l => l.endsWith('files.tar'))).toBe(false); + const vm = unpack('plain.tgz', 'vm-plain'); + expect(status(vm)).toEqual(status(join(d, 'plain'))); + expect(status(vm)).toEqual([' D gone', '?? "untracked space"', '?? untracked', 'A staged']); + expect(readFileSync(join(vm, 'with space'), 'utf8')).toBe('s\n'); + expect(sh('readlink link', vm).trim()).toBe('a'); + expect(existsSync(join(vm, '-C')) && existsSync(join(vm, '--files-from=a'))).toBe(true); + + 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')); + pack('wt', 'wt.tgz'); + const vmWt = unpack('wt.tgz', 'vm-wt'); + expect(existsSync(join(vmWt, 'ig'))).toBe(false); + expect(sh('git rev-parse --abbrev-ref HEAD && git status --short && git log --oneline | wc -l', vmWt).replace(/\s+/g, ' ').trim()).toBe('feat ?? .gitignore ?? u 2'); + + sh('git init -q empty && cd empty && echo a > a && git add a && git commit -qm one && rm a', d); + pack('empty', 'empty.tgz'); + expect(status(unpack('empty.tgz', 'vm-empty'))).toEqual([' D a']); + } finally { rmSync(d, { recursive: true, force: true }); } + }, 60_000); +}); + +describe(`ubi-runner usage and the quota preflight under the one-true-awk (#3679)${bwkAwk ? '' : ': skipped, no one-true-awk (install original-awk)'}`, () => { + /** Fake Ubicloud API: two VMs, a standard-4 owned by lane7 and a standard-16 another tool named; every call body goes to calls.log. */ + function fakeApi() { + const root = mkdtempSync(join(tmpdir(), 'ubi-bsd-awk-')); + const bin = join(root, 'bin'), log = join(root, 'calls.log'); + mkdirSync(bin); mkdirSync(join(root, 'state')); + 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)} +case "$body" in + *'"list"'*) printf 'eu-central-h1 ubirun-lane7-1761900000-ab12-t3\\neu-north-h1 someone-elses-vm\\n' > "$out" ;; + *lane7*'"show"'*) printf 'state: running\\nsize: standard-4\\n' > "$out" ;; + *'"show"'*) printf 'state: running\\nsize: standard-16\\n' > "$out" ;; + *) : > "$out" ;; +esac +printf 200 +`); + chmodSync(join(bin, 'curl'), 0o755); + const PATH = bsdPath(root, { awk: bwkAwk }); + const env = { ...process.env, PATH: `${bin}:${PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: join(root, 'state'), UBI_VM_RECORD: join(root, 'vms.tsv'), UBI_OWNER: 'lane7', UBI_REAP: '0', UBI_POLL_SECONDS: '1' }; + const run = (args: string[], extra: Record = {}) => Bun.spawnSync(['bash', RUNNER, ...args], { env: { ...env, ...extra }, timeout: 60_000 }); + return { root, run, env, calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : '') }; + } + + // Value: protects=usage runs under macOS's awk and the vCPU preflight refuses over its ceiling there; fails_when=an awk program in the runner uses syntax the one-true-awk rejects, so usage dies and the preflight silently passes; why_new=the runner's awk ran only under mawk/gawk in CI, so #3679 shipped green; seam=PATH (awk resolves to the one-true-awk) and a fake curl; no_claim=real Ubicloud sizes and quota + test.skipIf(!bwkAwk)('usage prints the per-owner table, and up refuses a create that would pass UBI_QUOTA_VCPUS', () => { + const f = fakeApi(); + try { + expect(Bun.spawnSync(['awk', '--version'], { env: f.env }).stdout.toString()).toContain('awk version'); + const usage = f.run(['usage']); + expect(usage.stderr.toString()).toBe(''); + expect(usage.exitCode).toBe(0); + expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\n(other) 1 16\nlane7 1 4 (you)\ntotal 2 20\n'); + const up = f.run(['up', '-s', 'standard-4'], { UBI_QUOTA_VCPUS: '16' }); + expect(up.exitCode).toBe(1); + expect(up.stderr.toString()).toContain('it needs 4 vCPUs and the project\'s VMs already use 20 of 16'); + expect(up.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted'); + expect(f.calls()).not.toContain('"create"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index fd6580884..6a97449fe 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -250,6 +250,25 @@ esac } finally { rmSync(f.root, { recursive: true, force: true }); } }, 30_000); + // Value: protects=a project with no VMs (Ubicloud answers vm list with a bare newline) reads as empty everywhere vm_rows feeds; fails_when=a blank list row becomes a VM named "-" again and reap dies on it or usage asks to show it; why_new=every fake list in this file had at least one VM, so #3678 shipped green; seam=none; no_claim=the real API's empty-body shape (taken from #3678's reading of ubi_cli.rb) + test('an empty project (vm list answers a bare or blank line) has no VMs: reap destroys nothing and exits 0, usage prints total 0 0, list --mine prints nothing (#3678)', () => { + for (const listed of ['\n', ' \n']) { + const f = fakeApi({ destroy: 200, listed }); + try { + const reap = f.run('reap'); + expect(reap.stderr.toString()).toContain('reap: no expired VMs'); + expect(reap.stderr.toString()).not.toContain('invalid VM name'); + expect(reap.exitCode).toBe(0); + const usage = f.run('usage'); + expect(usage.exitCode).toBe(0); + expect(usage.stdout.toString()).toBe('OWNER VMS VCPUS\ntotal 0 0\n'); + expect(f.run('list', '--mine').stdout.toString()).toBe(''); + expect(f.calls()).not.toContain('destroy'); + expect(f.calls()).not.toContain('"show"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + } + }); + 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 { @@ -376,6 +395,25 @@ test('pack ships a linked worktree with a self-contained .git on the checked-out } finally { rmSync(d, { recursive: true, force: true }); } }, 90_000); + // Value: protects=pack never lets macOS tar add AppleDouble ._* entries (files with extended attributes), which the VM's tree check would count as untracked files and fail the run with UBI_VM_TREE_MISMATCH; fails_when=a tar call in pack runs without COPYFILE_DISABLE=1; why_new=only Apple's tar reads the variable, so no Linux run can see the ._* entries themselves; seam=PATH (a tar shim records its environment, then runs the real tar); no_claim=Apple tar's metadata behavior itself (checked by hand on a Mac) + test('pack runs every tar with COPYFILE_DISABLE=1, for a checkout and for a plain directory', () => { + const d = mkdtempSync(join(tmpdir(), 'ubi-pack-copyfile-')); + try { + const realTar = Bun.which('tar'); + expect(realTar).toBeTruthy(); + mkdirSync(join(d, 'bin')); + writeFileSync(join(d, 'bin/tar'), `#!/usr/bin/env bash\necho "COPYFILE_DISABLE=${'$'}{COPYFILE_DISABLE:-unset}" >> ${JSON.stringify(join(d, 'tar.log'))}\nexec ${JSON.stringify(realTar)} "$@"\n`); + chmodSync(join(d, 'bin/tar'), 0o755); + const env = { ...process.env, PATH: `${join(d, 'bin')}:${process.env.PATH}`, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + const sh = (cmd: string) => { const r = Bun.spawnSync(['bash', '-c', cmd], { cwd: d, env, timeout: 30_000 }); expect(r.exitCode, r.stderr.toString()).toBe(0); }; + sh(`git init -q repo && cd repo && echo a > a && git add a && git commit -qm one && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack . > ../repo.tgz`); + sh(`mkdir plain && echo p > plain/p && bash ${JSON.stringify(join(DIR, 'ubi-runner.sh'))} pack plain > plain.tgz`); + const lines = readFileSync(join(d, 'tar.log'), 'utf8').trim().split('\n'); + expect(lines.length).toBe(3); + expect(lines.every(l => l === 'COPYFILE_DISABLE=1')).toBe(true); + } finally { rmSync(d, { recursive: true, force: true }); } + }); + 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"'); @@ -416,7 +454,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', ( ' *\'"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' + + ' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if [ -f "$listed.show-fails" ]; then echo down > "$out"; printf 503; elif 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' + @@ -711,6 +749,47 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', ( }, 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) + // Value: protects=the first up on a fresh project reaches its create with the default reap on; fails_when=the reap before up dies on an empty list again (#3678); why_new=no up case ran against a project with no VMs; seam=none; no_claim=real create and boot (fake API and ssh) + test('up on an empty project reaps nothing, creates its VM and comes up (#3678)', () => { + const f = fakeVm({ listed: '\n' }); + try { + const up = run(f, ['up']); + expect(up.stderr.toString()).toContain('reap: no expired VMs'); + expect(up.exitCode).toBe(0); + expect(f.calls()).toContain('"create"'); + expect(f.calls()).not.toContain('destroy'); + const name = up.stdout.toString().trim(); + expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); + expect(run(f, ['down', name]).exitCode).toBe(0); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + // Value: protects=usage never reports a partial or empty count as the project's real usage, and the quota preflight says when it cannot read usage instead of passing silently; fails_when=a failed vm list or vm show prints a total again (the empty-project fix's `|| true` did that) or the preflight drops its log line; why_new=only successful reads were tested, and #3679 showed an unreadable usage switching the preflight off without a word; seam=none; no_claim=the real API's failure bodies + test('usage exits 1 without a total when the VM list or a size read fails; the quota preflight logs that and leaves the call to the API', () => { + const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); + const listed = join(f.root, 'listed'); + try { + writeFileSync(`${listed}.list-fails`, ''); + const noList = run(f, ['usage']); + expect(noList.exitCode).toBe(1); + expect(noList.stdout.toString()).not.toContain('total'); + expect(noList.stderr.toString()).toContain("usage: could not list the project's VMs"); + const up = run(f, ['up'], { UBI_REAP: '0', UBI_QUOTA_VCPUS: '16' }); + expect(up.stderr.toString()).toContain("quota preflight: could not read the project's vCPU usage; the API's own answer decides"); + expect(f.calls()).toContain('"create"'); + rmSync(`${listed}.list-fails`); + const name = up.stdout.toString().trim(); + expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); + expect(run(f, ['down', name]).exitCode).toBe(0); + + writeFileSync(`${listed}.show-fails`, ''); + const noShow = run(f, ['usage']); + expect(noShow.exitCode).toBe(1); + expect(noShow.stdout.toString()).not.toContain('total'); + expect(noShow.stderr.toString()).toContain('usage: could not read the size of 1 of 1 VMs'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + 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 {