| 1 | #!/bin/sh |
| 2 | # Shell-level unit test for vllm-nonroot-entrypoint.sh. |
| 3 | # |
| 4 | # Runs on the host (no Docker, no GPU) by stubbing `vllm` with a shim that |
| 5 | # dumps its env + argv instead of actually serving. Exercises the wrapper's |
| 6 | # HOME/USER fallback behavior that can't be easily tested from buildkite |
| 7 | # (which would need a GPU to run `vllm serve --help`). |
| 8 | # |
| 9 | # Usage: |
| 10 | # bash docker/entrypoints/test_vllm_nonroot_entrypoint.sh |
| 11 | # Exits non-zero on the first failed assertion. |
| 12 | |
| 13 | set -eu |
| 14 | |
| 15 | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" |
| 16 | WRAPPER="${SCRIPT_DIR}/vllm-nonroot-entrypoint.sh" |
| 17 | |
| 18 | if [ ! -x "$WRAPPER" ]; then |
| 19 | echo "FAIL: wrapper not found or not executable: $WRAPPER" >&2 |
| 20 | exit 1 |
| 21 | fi |
| 22 | |
| 23 | WORKDIR="$(mktemp -d)" |
| 24 | trap 'rm -rf "$WORKDIR"' EXIT |
| 25 | |
| 26 | # Stub `vllm` on PATH. It dumps env + argv + cwd to stdout so we can assert. |
| 27 | mkdir -p "$WORKDIR/bin" |
| 28 | cat > "$WORKDIR/bin/vllm" <<'EOF' |
| 29 | #!/bin/sh |
| 30 | echo "ARGV=$*" |
| 31 | echo "HOME=${HOME-__unset__}" |
| 32 | echo "USER=${USER-__unset__}" |
| 33 | echo "LOGNAME=${LOGNAME-__unset__}" |
| 34 | echo "PWD=$(pwd)" |
| 35 | EOF |
| 36 | chmod +x "$WORKDIR/bin/vllm" |
| 37 | |
| 38 | run_wrapper() { |
| 39 | # Usage: run_wrapper <output_file> <env_kv>... -- <wrapper_arg>... |
| 40 | _out="$1"; shift |
| 41 | _env="" |
| 42 | while [ "${1:-}" != "--" ]; do |
| 43 | _env="$_env $1"; shift |
| 44 | done |
| 45 | shift |
| 46 | # shellcheck disable=SC2086 # word splitting is intentional: $_env holds multiple NAME=value assignments |
| 47 | env -i PATH="$WORKDIR/bin:/usr/bin:/bin" $_env "$WRAPPER" "$@" > "$_out" |
| 48 | } |
| 49 | |
| 50 | fail() { echo "FAIL: $*" >&2; echo "--- stdout ---" >&2; cat "$1" >&2; exit 1; } |
| 51 | |
| 52 | expect_default_home() { |
| 53 | _out="$1" |
| 54 | _case="$2" |
| 55 | if [ -w /home/vllm ]; then |
| 56 | expected_home="/home/vllm" |
| 57 | grep -q "^HOME=$expected_home\$" "$_out" \ |
| 58 | || fail "$_out" "$_case: HOME not set to $expected_home" |
| 59 | else |
| 60 | expected_home="/tmp/vllm-home.XXXXXX" |
| 61 | grep -Eq '^HOME=/tmp/vllm-home\.[^/]+$' "$_out" \ |
| 62 | || fail "$_out" "$_case: HOME not set to $expected_home" |
| 63 | fi |
| 64 | } |
| 65 | |
| 66 | # ----------------------------------------------------------------------------- |
| 67 | # Case 1: writable HOME and USER both set -> wrapper must leave them alone. |
| 68 | # ----------------------------------------------------------------------------- |
| 69 | case1_home="$WORKDIR/case1-home" |
| 70 | mkdir -p "$case1_home" |
| 71 | out="$WORKDIR/case1.out" |
| 72 | run_wrapper "$out" "HOME=$case1_home" "USER=alice" "LOGNAME=alice" -- --model foo |
| 73 | grep -q "^HOME=$case1_home\$" "$out" || fail "$out" "case1: HOME not preserved" |
| 74 | grep -q "^USER=alice\$" "$out" || fail "$out" "case1: USER not preserved" |
| 75 | grep -q "^LOGNAME=alice\$" "$out" || fail "$out" "case1: LOGNAME not preserved" |
| 76 | grep -q "^ARGV=serve --model foo\$" "$out" || fail "$out" "case1: ARGV wrong" |
| 77 | echo "PASS: case1 (writable HOME + USER preserved)" |
| 78 | |
| 79 | # ----------------------------------------------------------------------------- |
| 80 | # Case 2: HOME unset -> falls back to /home/vllm if writable, else |
| 81 | # /tmp/vllm-home.XXXXXX. |
| 82 | # ----------------------------------------------------------------------------- |
| 83 | # The wrapper checks whether the real /home/vllm exists and is writable. On |
| 84 | # dev machines /home/vllm typically does NOT exist, so the |
| 85 | # wrapper should fall to /tmp/vllm-home.XXXXXX. |
| 86 | out="$WORKDIR/case2.out" |
| 87 | run_wrapper "$out" -- --model bar |
| 88 | expect_default_home "$out" "case2" |
| 89 | grep -q "^USER=vllm\$" "$out" || fail "$out" "case2: USER not defaulted to vllm" |
| 90 | grep -q "^LOGNAME=vllm\$" "$out" || fail "$out" "case2: LOGNAME not defaulted to vllm" |
| 91 | grep -q "^ARGV=serve --model bar\$" "$out" || fail "$out" "case2: ARGV wrong" |
| 92 | echo "PASS: case2 (unset HOME falls back to $expected_home, USER defaulted)" |
| 93 | |
| 94 | # ----------------------------------------------------------------------------- |
| 95 | # Case 3: HOME set but unwritable -> must also fall back. |
| 96 | # ----------------------------------------------------------------------------- |
| 97 | ro_home="$WORKDIR/ro-home" |
| 98 | mkdir -p "$ro_home" |
| 99 | chmod 0500 "$ro_home" |
| 100 | out="$WORKDIR/case3.out" |
| 101 | run_wrapper "$out" "HOME=$ro_home" -- --model baz |
| 102 | expect_default_home "$out" "case3" |
| 103 | grep -q "^USER=vllm\$" "$out" || fail "$out" "case3: USER not defaulted" |
| 104 | chmod 0700 "$ro_home" |
| 105 | echo "PASS: case3 (unwritable HOME overridden)" |
| 106 | |
| 107 | # ----------------------------------------------------------------------------- |
| 108 | # Case 4: USER set but LOGNAME unset -> LOGNAME mirrors USER. |
| 109 | # ----------------------------------------------------------------------------- |
| 110 | case4_home="$WORKDIR/case4-home" |
| 111 | mkdir -p "$case4_home" |
| 112 | out="$WORKDIR/case4.out" |
| 113 | run_wrapper "$out" "HOME=$case4_home" "USER=carol" -- --model qux |
| 114 | grep -q "^USER=carol\$" "$out" || fail "$out" "case4: USER not preserved" |
| 115 | grep -q "^LOGNAME=carol\$" "$out" || fail "$out" "case4: LOGNAME not mirrored from USER" |
| 116 | echo "PASS: case4 (LOGNAME mirrors USER when unset)" |
| 117 | |
| 118 | # ----------------------------------------------------------------------------- |
| 119 | # Case 5: /etc/passwd is writable AND the current UID is not in it -> wrapper |
| 120 | # appends a synthetic entry. Uses the VLLM_PASSWD_FILE test hook so we don't |
| 121 | # touch the real /etc/passwd. |
| 122 | # ----------------------------------------------------------------------------- |
| 123 | fake_passwd="$WORKDIR/fake-passwd" |
| 124 | : > "$fake_passwd" # empty file, current UID definitely not present |
| 125 | case5_home="$WORKDIR/case5-home" |
| 126 | mkdir -p "$case5_home" |
| 127 | out="$WORKDIR/case5.out" |
| 128 | run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo |
| 129 | current_uid="$(id -u)" |
| 130 | current_gid="$(id -g)" |
| 131 | expected_line="vllm:x:${current_uid}:${current_gid}:vllm:${case5_home}:/bin/bash" |
| 132 | grep -Fx "$expected_line" "$fake_passwd" > /dev/null \ |
| 133 | || { echo "FAIL: case5: expected line not found in fake passwd:"; echo " expected: $expected_line"; echo " file contents:"; cat "$fake_passwd"; exit 1; } |
| 134 | echo "PASS: case5 (passwd entry appended for arbitrary UID)" |
| 135 | |
| 136 | # ----------------------------------------------------------------------------- |
| 137 | # Case 6: /etc/passwd is writable but current UID already has an entry -> |
| 138 | # wrapper must NOT duplicate the entry. |
| 139 | # ----------------------------------------------------------------------------- |
| 140 | fake_passwd="$WORKDIR/fake-passwd-prepopulated" |
| 141 | printf 'vllm:x:%s:%s:vllm:/home/vllm:/bin/bash\n' "$current_uid" "$current_gid" > "$fake_passwd" |
| 142 | out="$WORKDIR/case6.out" |
| 143 | run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo |
| 144 | # wc may count 0 or 1 depending on trailing newline, so count lines |
| 145 | # matching our UID instead for a robust check. |
| 146 | uid_lines="$(grep -c ":${current_uid}:" "$fake_passwd" || true)" |
| 147 | [ "$uid_lines" = "1" ] \ |
| 148 | || { echo "FAIL: case6: expected exactly one entry for UID $current_uid, got $uid_lines"; cat "$fake_passwd"; exit 1; } |
| 149 | echo "PASS: case6 (existing passwd entry not duplicated)" |
| 150 | |
| 151 | # ----------------------------------------------------------------------------- |
| 152 | # Case 7: /etc/passwd is NOT writable -> wrapper must NOT crash, just skip. |
| 153 | # Skipped when running as root, because root's DAC override means [ -w ... ] |
| 154 | # is always true regardless of mode bits -- the case can't be simulated. |
| 155 | # In the real deployment (non-root UID inside the container) this IS the |
| 156 | # relevant behavior and is what `_passwd_file is not writable` encodes. |
| 157 | # ----------------------------------------------------------------------------- |
| 158 | if [ "$(id -u)" = "0" ]; then |
| 159 | echo "SKIP: case7 (running as root; DAC override makes unwritable check meaningless)" |
| 160 | else |
| 161 | fake_passwd="$WORKDIR/ro-passwd" |
| 162 | : > "$fake_passwd" |
| 163 | chmod 0444 "$fake_passwd" |
| 164 | out="$WORKDIR/case7.out" |
| 165 | run_wrapper "$out" "HOME=$case5_home" "VLLM_PASSWD_FILE=$fake_passwd" -- --model foo |
| 166 | # File must remain empty (no write happened) and the wrapper exec'd |
| 167 | # `vllm serve` successfully (stdout contains ARGV line). |
| 168 | [ ! -s "$fake_passwd" ] \ |
| 169 | || { echo "FAIL: case7: RO passwd file was modified"; cat "$fake_passwd"; exit 1; } |
| 170 | grep -q "^ARGV=serve --model foo\$" "$out" || fail "$out" "case7: wrapper didn't exec vllm" |
| 171 | chmod 0600 "$fake_passwd" |
| 172 | echo "PASS: case7 (unwritable passwd file tolerated)" |
| 173 | fi |
| 174 | |
| 175 | # ----------------------------------------------------------------------------- |
| 176 | # Case 8: caller's writable CWD is preserved — wrapper must NOT chdir to HOME |
| 177 | # when cwd is usable. Protects relative-path workflows like |
| 178 | # `docker run -w /models ... --model ./llama.gguf`. |
| 179 | # ----------------------------------------------------------------------------- |
| 180 | case8_home="$WORKDIR/case8-home" |
| 181 | mkdir -p "$case8_home" |
| 182 | case8_cwd="$WORKDIR/case8-cwd" |
| 183 | mkdir -p "$case8_cwd" |
| 184 | out="$WORKDIR/case8.out" |
| 185 | (cd "$case8_cwd" && run_wrapper "$out" "HOME=$case8_home" "USER=alice" "LOGNAME=alice" -- --model ./relpath) |
| 186 | grep -q "^PWD=$case8_cwd\$" "$out" \ |
| 187 | || fail "$out" "case8: writable cwd not preserved (got $(grep '^PWD=' "$out"))" |
| 188 | grep -q "^ARGV=serve --model \\./relpath\$" "$out" \ |
| 189 | || fail "$out" "case8: relative argv not preserved" |
| 190 | echo "PASS: case8 (writable cwd preserved; relative argv still resolves from caller's cwd)" |
| 191 | |
| 192 | # ----------------------------------------------------------------------------- |
| 193 | # Case 9: read-only cwd is ALSO preserved. A caller who mounts a read-only |
| 194 | # model directory at the container's cwd (e.g. `docker run -w /models` with |
| 195 | # /models bind-mounted ro) expects relative argv like `--model ./foo.gguf` |
| 196 | # to resolve against /models. An earlier version of this wrapper rewrote |
| 197 | # read-only cwd to $HOME and broke that workflow; this case guards against |
| 198 | # the regression returning. |
| 199 | # ----------------------------------------------------------------------------- |
| 200 | case9_home="$WORKDIR/case9-home" |
| 201 | mkdir -p "$case9_home" |
| 202 | case9_ro="$WORKDIR/case9-ro" |
| 203 | mkdir -p "$case9_ro" |
| 204 | chmod 0555 "$case9_ro" |
| 205 | out="$WORKDIR/case9.out" |
| 206 | (cd "$case9_ro" && run_wrapper "$out" "HOME=$case9_home" "USER=alice" "LOGNAME=alice" -- --model ./foo) |
| 207 | grep -q "^PWD=$case9_ro\$" "$out" \ |
| 208 | || fail "$out" "case9: read-only cwd was rewritten (got $(grep '^PWD=' "$out"))" |
| 209 | grep -q "^ARGV=serve --model \\./foo\$" "$out" \ |
| 210 | || fail "$out" "case9: relative argv not preserved" |
| 211 | chmod 0700 "$case9_ro" |
| 212 | echo "PASS: case9 (read-only cwd preserved; relative argv still resolves from caller's cwd)" |
| 213 | |
| 214 | # ----------------------------------------------------------------------------- |
| 215 | # Case 10: truly inaccessible cwd (no search bit) DOES fall back to $HOME. |
| 216 | # Skipped as root because DAC override lets root cd into 0000 directories. |
| 217 | # ----------------------------------------------------------------------------- |
| 218 | if [ "$(id -u)" = "0" ]; then |
| 219 | echo "SKIP: case10 (running as root; DAC override makes inaccessible cwd untestable)" |
| 220 | else |
| 221 | case10_home="$WORKDIR/case10-home" |
| 222 | mkdir -p "$case10_home" |
| 223 | case10_cwd="$WORKDIR/case10-cwd" |
| 224 | mkdir -p "$case10_cwd" |
| 225 | out="$WORKDIR/case10.out" |
| 226 | # Make cwd genuinely inaccessible (mode 0000 = no search bit -> cd . |
| 227 | # fails with EACCES). Use absolute paths for chmod so our own test |
| 228 | # cleanup still works without needing search perm on the dir. |
| 229 | ( |
| 230 | cd "$case10_cwd" |
| 231 | chmod 0000 "$case10_cwd" |
| 232 | run_wrapper "$out" "HOME=$case10_home" "USER=alice" "LOGNAME=alice" -- --model foo |
| 233 | ) |
| 234 | chmod 0700 "$case10_cwd" |
| 235 | grep -q "^PWD=$case10_home\$" "$out" \ |
| 236 | || fail "$out" "case10: inaccessible cwd not overridden to HOME (got $(grep '^PWD=' "$out"))" |
| 237 | echo "PASS: case10 (inaccessible cwd falls back to \$HOME)" |
| 238 | fi |
| 239 | |
| 240 | # ----------------------------------------------------------------------------- |
| 241 | # Case 11: if /tmp cannot create a private fallback dir, wrapper uses /tmp as |
| 242 | # the last-resort HOME instead of leaving HOME empty under set -eu. |
| 243 | # ----------------------------------------------------------------------------- |
| 244 | if [ -w /home/vllm ]; then |
| 245 | echo "SKIP: case11 (/home/vllm is writable; mktemp fallback path is not used)" |
| 246 | else |
| 247 | cat > "$WORKDIR/bin/mktemp" <<'EOF' |
| 248 | #!/bin/sh |
| 249 | exit 1 |
| 250 | EOF |
| 251 | chmod +x "$WORKDIR/bin/mktemp" |
| 252 | |
| 253 | out="$WORKDIR/case11.out" |
| 254 | run_wrapper "$out" -- --model no-mktemp |
| 255 | rm -f "$WORKDIR/bin/mktemp" |
| 256 | |
| 257 | grep -q "^HOME=/tmp\$" "$out" \ |
| 258 | || fail "$out" "case11: mktemp failure did not fall back to /tmp" |
| 259 | grep -q "^USER=vllm\$" "$out" || fail "$out" "case11: USER not defaulted" |
| 260 | grep -q "^LOGNAME=vllm\$" "$out" || fail "$out" "case11: LOGNAME not defaulted" |
| 261 | grep -q "^ARGV=serve --model no-mktemp\$" "$out" || fail "$out" "case11: ARGV wrong" |
| 262 | echo "PASS: case11 (mktemp failure falls back to /tmp)" |
| 263 | fi |
| 264 | |
| 265 | echo "" |
| 266 | echo "ALL CASES PASSED." |