mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
ubicloud: pull destinations are rechecked at pull time, verify parses options by arity, a finished command keeps its status, the remote sid outlives the wrapper; docs audit
Pass-2 (Codex adversarial on the delta): an earlier pull could plant a symlink where a later config pull lands, so under verify every destination is resolved again (symlinks followed) right before its pull and a refused pull exits 1; '--dest --trust-config' made an option value act as the trust flag, so verify parses by arity; a command that finished in the instant before the watchdog's kill exited 0 and was still reported as 124; the wrapper removed the sid file while a background child kept the channel open, so the file now stays until remote_cancel reads it. Docs audit (document-release, edit mode): TTL suffix and owner rule in TESTING_INTERNALS, the reaper as the exception to 'no sweep', the docs/skills.md transcript uses a neutral owner; CHANGELOG and the runner header corrected on the two points it deferred. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e79209c47e
commit
7c92dbeece
7 files changed
+92
-28
No files matched your search
+3
-3
@@ -7,10 +7,10 @@
|
||||
### Added
|
||||
- **`ubicloud/SKILL.md.tmpl`, `bin/gstack-ubi`**: the skill: set/unset prerequisite checks (never `env`), `UBI_OWNER` on every machine, a watched launch (a waited-on background operation or `gstack-detach`), the quoted recovery line, one committed verify per repository, how to read exit 124 and a quota refusal, and the resume checklist. Classified `CLAUDE_ONLY` in `lib/doctor-components.ts`; routed from the root `SKILL.md`; listed in README, AGENTS.md, docs/skills.md and docs/PROJECT_STRUCTURE.md.
|
||||
- **TTL names and a reaper**: a generated VM name ends in `-t<hours>` (`UBI_TTL_HOURS`, default 4; 0 omits it). `reap` destroys every VM in the project whose TTL ended more than ten minutes ago, whichever owner created it, and every `up` runs it first (`UBI_REAP=0` skips it). Names without a TTL, including the ones `pregate-remote.sh` builds, are never reaped.
|
||||
- **Quota preflight**: `up` adds the size's vCPUs to the project's running VMs and refuses before the create request when that exceeds the known ceiling (`UBI_QUOTA_VCPUS`, or the maximum the last API refusal printed, remembered in the state root); `UBI_QUOTA_WAIT=<seconds>` polls for room instead of refusing. The per-owner `usage` table is printed either way.
|
||||
- **Quota preflight**: `up` adds the size's vCPUs to the project's running VMs and refuses before the create request when that exceeds the known ceiling (`UBI_QUOTA_VCPUS`, or the maximum the last API refusal printed, remembered in the state root); `UBI_QUOTA_WAIT=<seconds>` polls for room instead of refusing. Both refusals (the preflight's and the API's) print the per-owner `usage` table.
|
||||
- **Watchdog and heartbeat**: `run`, `verify` and `ssh NAME COMMAND` stream the remote output (both streams kept in the VM's state dir) through a watchdog: a heartbeat every `UBI_HEARTBEAT` seconds (300), and a command silent for `UBI_IDLE_TIMEOUT` seconds (1800) or older than `UBI_RUN_TIMEOUT` (0, unlimited) is killed in its own session with exit 124 and its last 40 lines on stderr. `run` also prints the VM's TTL and its `down` recovery command as soon as the VM is ready.
|
||||
- **`verify`**: runs the repository's committed `.gstack/ubicloud.json` (setup, command, size, location, storage, image, env, pass, pull; flags and a trailing `-- COMMAND` override it) and refuses without the file or a command, so every lane runs one definition instead of its own wrapper. The file is repository content and gets no reach beyond the checkout: `setup` and each `pull` destination must be relative paths that resolve inside `--src` (absolute, `~` and `..` paths are refused before any API call), and a name under `pass` is forwarded only when the command line grants it (`--pass NAME`, or every listed name with `--trust-config`). `--pass` never forwards `UBICLOUD_*`, from the file or the command line.
|
||||
- **Watched commands keep their stdin and exit codes**: `ssh NAME COMMAND` hands the caller's stdin to the remote command (`printf payload | gstack-ubi ssh NAME 'cat > file'` wrote an empty file once the watchdog backgrounded the client), a command that finished while the watchdog read the clock is never reported as a timeout, a hung setup script exits 124 like a hung command instead of 1, Ctrl-C on a watched command takes the ssh session down with it, and when the watchdog fires the runner also ends the command's session on the VM (`setsid` there, leader pid in `~/.ubirun-<phase>-<runner pid>.sid`, `pkill -s` bounded to 20 s), since killing the local client alone leaves a silent remote command running until it next writes.
|
||||
- **`verify`**: runs the repository's committed `.gstack/ubicloud.json` (setup, command, size, location, storage, image, env, pass, pull; flags and a trailing `-- COMMAND` override it) and refuses without the file or a command, so every lane runs one definition instead of its own wrapper. The file is repository content and gets no reach beyond the checkout: `setup` and each `pull` destination must be relative paths that resolve inside `--src` (absolute, `~` and `..` paths are refused before any API call, and under `verify` every pull destination is resolved again, symlinks followed, right before its pull, so a symlink an earlier pull planted cannot redirect a later one; a refused pull makes the run exit 1), and a name under `pass` is forwarded only when the command line grants it (`--pass NAME`, or every listed name with `--trust-config`; `verify` parses its options by arity, so an option value can never act as that flag). `--pass` never forwards `UBICLOUD_*`, from the file or the command line.
|
||||
- **Watched commands keep their stdin and exit codes**: `ssh NAME COMMAND` hands the caller's stdin to the remote command (`printf payload | gstack-ubi ssh NAME 'cat > file'` wrote an empty file once the watchdog backgrounded the client), a command that finished while the watchdog read the clock, or in the instant before its kill, keeps its own exit status instead of 124, a hung setup script exits 124 like a hung command instead of 1, Ctrl-C on a watched command takes the ssh session down with it, and when the watchdog fires the runner also ends the command's session on the VM (`setsid` there, leader pid in `~/.ubirun-<phase>-<runner pid>.sid`, kept until the cancel reads it so a background child the command left behind is still reachable, `pkill -s` bounded to 20 s), since killing the local client alone leaves a silent remote command running until it next writes.
|
||||
- **Quota ceiling and reaper outcomes**: the maximum a refusal cached is trusted for a day, then the API's own answer decides again; the refusal line says where the ceiling came from and names `UBI_QUOTA_VCPUS`, and its `vCPU quota exhausted` wording is the one `pregate-remote.sh` retries once on. `reap` returns 1 when the list fails (nothing destroyed) or an expired VM could not be destroyed, instead of 0 either way; a TTL suffix is read in base 10 (`UBI_TTL_HOURS` takes 0 to 999 with no leading zero).
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -962,16 +962,18 @@ the CSO private-state checks), AppArmor blocks the unprivileged user
|
||||
namespaces Chromium's sandbox needs, and `clang` and `python3-venv` are absent
|
||||
(required by the Dia readiness and Python runner tests).
|
||||
|
||||
VMs are named `ubirun-<owner>-<epoch>-<hex>`; `<owner>` is `UBI_OWNER` (set it
|
||||
to the thread id on a Capy machine), else a stable per-machine id. Every exit
|
||||
VMs are named `ubirun-<owner>-<epoch>-<hex>`, plus a `-t<hours>` TTL suffix
|
||||
(below); `<owner>` is `UBI_OWNER` (set it to the thread id on a Capy machine;
|
||||
lowercase letters and digits, at most 12), else a stable per-machine id. Every exit
|
||||
path destroys the client's own VM (`trap` on EXIT, INT, TERM, HUP and QUIT), and
|
||||
`down` fails loudly (`FAILED to destroy <location>/<name> (HTTP <code>)`) unless
|
||||
the VM is confirmed gone. `list --mine` shows this owner's VMs, `usage` the
|
||||
project's, and `records` the VM-id scratch file (`UBI_VM_RECORD`, default
|
||||
`~/.capy/work/ubi-runner/vms.tsv`, else `<state root>/vms.tsv`): every VM is
|
||||
recorded there before the create call and forgotten only after a confirmed
|
||||
destroy, so a killed runner leaves a name to clean up. Nothing sweeps stale VMs
|
||||
by default, because the project quota is shared with other agents' runners; set
|
||||
destroy, so a killed runner leaves a name to clean up. No age-based sweep runs
|
||||
by default, because the project quota is shared with other agents' runners (the
|
||||
TTL reaper below destroys only a VM whose own `-t<hours>` TTL has ended); set
|
||||
`UBI_GC_HOURS` to a positive number (or run `gc HOURS`; `gc 0` is a no-op) to
|
||||
sweep only this owner's VMs older than that. For other commands, use the runner
|
||||
directly: `scripts/ubicloud/ubi-runner.sh run --setup <script> -- '<command>'`,
|
||||
@@ -1017,9 +1019,10 @@ machine swap, in this order:
|
||||
on the base and on the candidate from the same VM size and location, never
|
||||
one side local and the other remote.
|
||||
4. Profiling very large imports (a whole-repo ingest, the full free suite under
|
||||
a profiler) is warned about before it starts: it holds the VM past
|
||||
`UBI_GC_HOURS` and the sentinel arrives hours later; name the expected
|
||||
duration in the thread before launching.
|
||||
a profiler) is warned about before it starts: it holds the VM for hours, and
|
||||
any other lane's `up` reaps it once its TTL (`UBI_TTL_HOURS`, default 4)
|
||||
plus ten minutes has passed, so set `UBI_TTL_HOURS` above the expected
|
||||
duration and name that duration in the thread before launching.
|
||||
|
||||
## Cloud sandboxes (Vercel / Conductor cloud workspaces)
|
||||
|
||||
|
||||
+4
-4
@@ -894,14 +894,14 @@ runner destroys its VM on every exit path only while it is alive.
|
||||
```
|
||||
You: /ubicloud run the full verify
|
||||
|
||||
Claude: UBICLOUD_API_KEY: set; UBI_OWNER=gsta114
|
||||
Claude: UBICLOUD_API_KEY: set; UBI_OWNER=lane7
|
||||
gstack-ubi verify (.gstack/ubicloud.json: bootstrap.sh, "bash scripts/verify.sh", standard-16)
|
||||
ubi-runner: reap: no expired VMs
|
||||
ubi-runner: creating eu-central-h1/ubirun-gsta114-1761500000-9f3a2c10-t4 (standard-16)
|
||||
ubi-runner: ubirun-gsta114-1761500000-9f3a2c10-t4 carries a 4h TTL (any later run of this script reaps it); if this process dies first: gstack-ubi down ubirun-gsta114-1761500000-9f3a2c10-t4
|
||||
ubi-runner: creating eu-central-h1/ubirun-lane7-1761500000-9f3a2c10-t4 (standard-16)
|
||||
ubi-runner: ubirun-lane7-1761500000-9f3a2c10-t4 carries a 4h TTL (any later run of this script reaps it); if this process dies first: gstack-ubi down ubirun-lane7-1761500000-9f3a2c10-t4
|
||||
ubi-runner: heartbeat: command 300s elapsed, last output 2s ago
|
||||
ubi-runner: command exited 0 after 1140s
|
||||
ubi-runner: destroyed eu-central-h1/ubirun-gsta114-1761500000-9f3a2c10-t4
|
||||
ubi-runner: destroyed eu-central-h1/ubirun-lane7-1761500000-9f3a2c10-t4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# Needs only bash, curl, python3, ssh, ssh-keygen, and tar locally, so it works
|
||||
# from dev boxes, containers, and cloud sandboxes.
|
||||
#
|
||||
# Ownership: VMs are named ubirun-<owner>-<epoch>-<suffix>. <owner> is UBI_OWNER
|
||||
# Ownership: VMs are named ubirun-<owner>-<epoch>-<suffix>[-t<hours>]. <owner> is UBI_OWNER
|
||||
# (lowercased, letters and digits only, at most 12 characters, starting with a
|
||||
# letter) or a random per-machine id kept in $STATE_ROOT/owner-id. Ubicloud names
|
||||
# allow 63 characters of [a-z0-9-], so a full name stays under 40.
|
||||
@@ -188,7 +188,7 @@ sq() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
# writes to the closed channel.
|
||||
remote_cancel() {
|
||||
local name=$1 sidf=$2 cpid i=0
|
||||
remote "$name" "sid=\$(cat $sidf 2>/dev/null); [ -z \"\$sid\" ] || { pkill -TERM -s \"\$sid\"; sleep 2; pkill -KILL -s \"\$sid\"; }; true" >/dev/null 2>&1 </dev/null &
|
||||
remote "$name" "sid=\$(cat $sidf 2>/dev/null); rm -f $sidf; [ -z \"\$sid\" ] || { pkill -TERM -s \"\$sid\"; sleep 2; pkill -KILL -s \"\$sid\"; }; true" >/dev/null 2>&1 </dev/null &
|
||||
cpid=$!
|
||||
while kill -0 "$cpid" 2>/dev/null && [ "$i" -lt 20 ]; do sleep 1; i=$(( i + 1 )); done
|
||||
kill "$cpid" 2>/dev/null || true
|
||||
@@ -211,7 +211,10 @@ watched() {
|
||||
out="$(state_dir "$name")/$phase.log"
|
||||
: >"$out"
|
||||
inner=$(sq "printf %s \$\$ >$sidf; $cmd")
|
||||
cmd="if command -v setsid >/dev/null 2>&1; then setsid -w bash -c $inner; else bash -c $inner; fi; rc=\$?; rm -f $sidf; exit \$rc"
|
||||
# The sid file outlives the command on purpose: a background child it left
|
||||
# behind keeps the ssh channel open after the wrapper exits, and remote_cancel
|
||||
# still needs the session id to end it. remote_cancel removes the file.
|
||||
cmd="if command -v setsid >/dev/null 2>&1; then setsid -w bash -c $inner; else bash -c $inner; fi"
|
||||
# The caller's stdin is handed to the background ssh explicitly (a background
|
||||
# job in a non-interactive shell would otherwise read /dev/null), so piped
|
||||
# input reaches the remote command as it did before the watchdog.
|
||||
@@ -245,8 +248,14 @@ watched() {
|
||||
log "watchdog: $phase $why; killing it. Last output:"
|
||||
tail -n 40 "$out" >&2
|
||||
kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || rc=$?
|
||||
trap - INT TERM; eval "$old_traps"
|
||||
# A command killed by the watchdog never exits 0; a 0 here means it
|
||||
# finished in the instant before the kill, and that result stands.
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
log "watchdog: $phase finished before it could be killed; its exit 0 stands"
|
||||
return 0
|
||||
fi
|
||||
remote_cancel "$name" "$sidf"
|
||||
return 124
|
||||
fi
|
||||
@@ -632,6 +641,19 @@ pass_name() {
|
||||
case $1 in UBICLOUD_*) die "--pass $1: the runner never forwards its own Ubicloud credentials to a VM" ;; esac
|
||||
}
|
||||
|
||||
# inside_dir DIR ROOT: DIR exists (created if needed) and resolves, symlinks
|
||||
# followed, to ROOT or below it.
|
||||
inside_dir() {
|
||||
local a b
|
||||
mkdir -p "$1" 2>/dev/null || return 1
|
||||
a=$(cd "$1" 2>/dev/null && pwd -P) || return 1
|
||||
b=$(cd "$2" 2>/dev/null && pwd -P) || return 1
|
||||
[ "$a" = "$b" ] || [[ "$a" = "$b"/* ]]
|
||||
}
|
||||
|
||||
# PULL_ROOT: set by verify; every pull destination must resolve inside it at
|
||||
# pull time (an earlier pull may have placed a symlink where a later one lands).
|
||||
PULL_ROOT=""
|
||||
cmd_run() {
|
||||
local up_args=() src=. dest="" setup="" keep=0 pulls=() envs=()
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -696,6 +718,11 @@ cmd_run() {
|
||||
|
||||
local p
|
||||
for p in ${pulls[@]+"${pulls[@]}"}; do
|
||||
if [ -n "$PULL_ROOT" ] && ! inside_dir "${p#*:}" "$PULL_ROOT"; then
|
||||
log "pull refused: ${p#*:} resolves outside $PULL_ROOT"
|
||||
[ "$rc" -ne 0 ] || rc=1
|
||||
continue
|
||||
fi
|
||||
cmd_pull "$name" "${p%%:*}" "${p#*:}" || log "pull failed: $p"
|
||||
done
|
||||
return "$rc"
|
||||
@@ -762,12 +789,16 @@ print("cfg_cmd=" + q(c.get("command", "")))
|
||||
rm -f "$err"
|
||||
eval "$parsed"
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = -- ]; then shift; user_cmd=("$@"); break; fi
|
||||
case $1 in
|
||||
--trust-config) trust=1; shift; continue ;;
|
||||
--pass) granted+=("${2:-}") ;;
|
||||
--) shift; user_cmd=("$@"); break ;;
|
||||
--trust-config) trust=1; shift ;;
|
||||
--keep) user_pre+=("$1"); shift ;;
|
||||
--pass|-S|--storage|-s|--size|-l|--location|-n|--name|-b|--image|--src|--dest|--setup|--env|--pull)
|
||||
[ $# -ge 2 ] || die "verify: $1 needs a value"
|
||||
[ "$1" != --pass ] || granted+=("$2")
|
||||
user_pre+=("$1" "$2"); shift 2 ;;
|
||||
*) die "verify: unknown option $1" ;;
|
||||
esac
|
||||
user_pre+=("$1"); shift
|
||||
done
|
||||
for n in ${cfg_pass[@]+"${cfg_pass[@]}"}; do
|
||||
pass_name "$n"
|
||||
@@ -779,6 +810,7 @@ print("cfg_cmd=" + q(c.get("command", "")))
|
||||
[ ${#user_cmd[@]} -gt 0 ] || [ -n "$cfg_cmd" ] || die "verify: $cfg names no command and none was given after --"
|
||||
[ ${#user_cmd[@]} -gt 0 ] || user_cmd=("$cfg_cmd")
|
||||
log "verify: $cfg"
|
||||
PULL_ROOT=$(cd "$src" && pwd)
|
||||
cmd_run ${cfg_args[@]+"${cfg_args[@]}"} ${cfg_setup:+--setup "$cfg_setup"} --src "$src" ${user_pre[@]+"${user_pre[@]}"} -- "${user_cmd[@]}"
|
||||
}
|
||||
|
||||
@@ -791,8 +823,9 @@ usage: ubi-runner.sh <command> [args]
|
||||
up + sync + setup + command + pull + destroy; exits with COMMAND's status
|
||||
verify [run opts] [--trust-config] [-- COMMAND]
|
||||
run the repository's committed verify (.gstack/ubicloud.json under --src);
|
||||
its setup and pull paths stay inside --src, and its pass names are
|
||||
forwarded only when granted here (--pass NAME, or all: --trust-config)
|
||||
its setup script and every pull destination stay inside --src (checked
|
||||
again at pull time), and its pass names are forwarded only when granted
|
||||
here (--pass NAME, or all: --trust-config)
|
||||
up [-s SIZE] [-l LOCATION] [-n NAME] [-S STORAGE_GIB] [-b IMAGE]
|
||||
create a VM and wait for SSH; prints its name
|
||||
(STORAGE_GIB: one of $ALLOWED_STORAGE)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* call when the token is absent.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
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';
|
||||
|
||||
@@ -333,6 +333,7 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
' *"cat > ~/.ubirun-env"*) cat > "$root/ubirun-env" ;;\n' +
|
||||
' *"cat > ~/.ubirun-setup.sh"*) cat > "$root/ubirun-setup.sh" ;;\n' +
|
||||
' *"tar -xzf -"*) cat > /dev/null ;;\n' +
|
||||
' *"tar -czf -"*) base=${cmd##* }; tar -czf - -C "$root/remote-fs" "$base" ;;\n' +
|
||||
' *"bash -l ~/.ubirun-setup.sh"*) echo "setup start"; if [ "$(cat "$root/setup-mode")" = hang ]; then sleep 20; fi; echo "setup done" ;;\n' +
|
||||
' *"bash -lc "*) if [ -f "$root/command-mode" ]; then bash -c "$(cat "$root/command-mode")"; else echo "ran: $cmd"; fi ;;\n' +
|
||||
' *) : ;;\n' +
|
||||
@@ -372,7 +373,8 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
expect(err).toContain('setup failed (exit 124)');
|
||||
expect(f.ssh()).not.toContain('bash -lc');
|
||||
expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/cat ~\/\.ubirun-setup-\d+\.sid/);
|
||||
expect(f.ssh()).toMatch(/setsid -w bash -c 'printf %s \$\$ >~\/\.ubirun-setup-\d+\.sid; cd work\/[^ ]+ && \. ~\/\.ubirun-env && bash -l ~\/\.ubirun-setup\.sh'; else bash -c '[^']*'; fi; rc=\$\?; rm -f ~\/\.ubirun-setup-\d+\.sid; exit \$rc/);
|
||||
expect(f.ssh()).toMatch(/setsid -w bash -c 'printf %s \$\$ >~\/\.ubirun-setup-\d+\.sid; cd work\/[^ ]+ && \. ~\/\.ubirun-env && bash -l ~\/\.ubirun-setup\.sh'; else bash -c '[^']*'; fi$/m);
|
||||
expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/rm -f ~\/\.ubirun-setup-\d+\.sid; \[ -z "\$sid" \] \|\| \{ pkill -TERM -s "\$sid"; sleep 2; pkill -KILL -s "\$sid"; \}/);
|
||||
expect(f.calls()).toContain('destroy');
|
||||
expect(err).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t4/);
|
||||
expect(f.listed()).toBe('');
|
||||
@@ -440,6 +442,13 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
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']);
|
||||
@@ -478,6 +487,23 @@ describe('ubi-runner run, verify and quota against a fake API and a fake ssh', (
|
||||
} 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'] });
|
||||
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 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 {
|
||||
|
||||
+2
-1
@@ -468,7 +468,8 @@ $U verify -- 'pnpm e2e' # a different command on the same bootstrap
|
||||
The file is repository content, so it gets no reach beyond the checkout on its
|
||||
own: `setup` and the local side of each `pull` must be relative paths that
|
||||
resolve inside `--src` (absolute, `~` and `..` paths are refused before any
|
||||
API call), and a name under `pass` is forwarded only when your command line
|
||||
API call, and each destination is resolved again right before its pull), and a
|
||||
name under `pass` is forwarded only when your command line
|
||||
grants it with `--pass NAME` or `--trust-config`. Run `verify` only on a tree
|
||||
you have reviewed: its bootstrap and command run on the VM with whatever you
|
||||
granted.
|
||||
|
||||
@@ -144,7 +144,8 @@ $U verify -- 'pnpm e2e' # a different command on the same bootstrap
|
||||
The file is repository content, so it gets no reach beyond the checkout on its
|
||||
own: `setup` and the local side of each `pull` must be relative paths that
|
||||
resolve inside `--src` (absolute, `~` and `..` paths are refused before any
|
||||
API call), and a name under `pass` is forwarded only when your command line
|
||||
API call, and each destination is resolved again right before its pull), and a
|
||||
name under `pass` is forwarded only when your command line
|
||||
grants it with `--pass NAME` or `--trust-config`. Run `verify` only on a tree
|
||||
you have reviewed: its bootstrap and command run on the VM with whatever you
|
||||
granted.
|
||||
|
||||
Reference in new issue
Block a user