Monorepo for Tangled
tangled.org
1#!/usr/bin/env bash
2set -euo pipefail
3# dont export big captures like `out=$(run_vm ...)` into the environment.
4set +a
5# extglob enables the *(...) pattern strip_ansi uses for pure-bash CSI stripping
6shopt -s extglob
7# note: needs `sudo modprobe vhost_vsock`!
8
9log() {
10 printf "\n\033[1;36m>>> %s\033[0m\n" "$*"
11}
12
13_strip_ansi_stream() {
14 local line
15 while IFS= read -r line || [ -n "$line" ]; do
16 line=${line//$'\e'\[*([0-9;])[a-zA-Z]/}
17 line=${line//$'\e'\([a-zA-Z]/}
18 printf '%s\n' "$line"
19 done
20}
21
22strip_ansi() {
23 if [ "$#" -gt 0 ]; then
24 local f
25 for f in "$@"; do
26 [ -r "$f" ] && _strip_ansi_stream < "$f"
27 done
28 else
29 _strip_ansi_stream
30 fi
31}
32
33# check_needles OUT NEEDLES...
34# each needle is an extended regex matched per line (mirrors `grep -qE`).
35check_needles() {
36 local out="$1"
37 shift
38
39 local -a lines
40 mapfile -t lines <<< "$out"
41
42 # strip ANSI per line (each line is short, so this stays cheap)
43 local i l
44 for i in "${!lines[@]}"; do
45 l=${lines[i]}
46 l=${l//$'\e'\[*([0-9;])[a-zA-Z]/}
47 l=${l//$'\e'\([a-zA-Z]/}
48 lines[i]=$l
49 done
50
51 local needle missing=0 line hit
52 for needle in "$@"; do
53 hit=0
54 for line in "${lines[@]}"; do
55 if [[ $line =~ $needle ]]; then
56 hit=1
57 break
58 fi
59 done
60 if [ "$hit" -eq 0 ]; then
61 echo "error: output missing '$needle'" >&2
62 missing=1
63 fi
64 done
65
66 if [ "$missing" -ne 0 ]; then
67 printf '%s\n' "${lines[@]}" >&2
68 return 1
69 fi
70}
71
72declare -a TEST_NAMES=()
73declare -a TEST_STATUSES=()
74declare -a TEST_TIMES=()
75SKIP_TEST_RC=200
76
77get_time_ms() {
78 local t="${EPOCHREALTIME:-}"
79 if [[ "$t" == *.* ]]; then
80 local secs="${t%.*}"
81 local subs="${t#*.}"
82 subs="${subs:0:3}"
83 while [ "${#subs}" -lt 3 ]; do
84 subs="${subs}0"
85 done
86 echo "${secs}${subs}"
87 else
88 echo "$(date +%s)000"
89 fi
90}
91
92format_duration() {
93 local ms=$1
94 local secs=$((ms / 1000))
95 local rem=$((ms % 1000))
96 printf "%d.%03ds" "$secs" "$rem"
97}
98
99print_summary() {
100 if [ "${#TEST_NAMES[@]}" -eq 0 ]; then
101 return
102 fi
103 printf "\n"
104 log "test summary"
105 echo "========================================="
106 local passed_count=0
107 local skipped_count=0
108 local failed_count=0
109 local total_time=0
110 for i in "${!TEST_NAMES[@]}"; do
111 local name="${TEST_NAMES[$i]}"
112 local status="${TEST_STATUSES[$i]}"
113 local duration_ms="${TEST_TIMES[$i]}"
114 local duration_str
115 duration_str=$(format_duration "$duration_ms")
116
117 local status_color="\033[0;32m"
118 if [ "$status" = "Failed" ]; then
119 status_color="\033[0;31m"
120 failed_count=$((failed_count + 1))
121 elif [ "$status" = "Skipped" ]; then
122 status_color="\033[0;33m"
123 skipped_count=$((skipped_count + 1))
124 else
125 passed_count=$((passed_count + 1))
126 fi
127 total_time=$((total_time + duration_ms))
128
129 printf " %-30s %b%-8b\033[0m %s\n" "$name" "$status_color" "$status" "$duration_str"
130 done
131 echo "-----------------------------------------"
132 local total_tests="${#TEST_NAMES[@]}"
133 local total_time_str
134 total_time_str=$(format_duration "$total_time")
135 printf " total: %d tests, %d passed, %d skipped, %d failed\n" "$total_tests" "$passed_count" "$skipped_count" "$failed_count"
136 printf " total execution time: %s\n" "$total_time_str"
137 echo "========================================="
138}
139
140skip_test() {
141 echo "skipped: $*"
142 return "$SKIP_TEST_RC"
143}
144
145host_is_nixos() {
146 [ -e /etc/NIXOS ] && return 0
147 [ -r /etc/os-release ] && grep -q '^ID=nixos$' /etc/os-release
148}
149
150JOBS="${JOBS:-4}"
151while [[ $# -gt 0 ]]; do
152 case "$1" in
153 -j | --jobs)
154 JOBS="$2"
155 shift 2
156 ;;
157 --jobs=*)
158 JOBS="${1#*=}"
159 shift
160 ;;
161 --only)
162 TEST_ONLY="$2"
163 shift 2
164 ;;
165 --only=*)
166 TEST_ONLY="${1#*=}"
167 shift
168 ;;
169 *)
170 echo "unknown argument: $1" >&2
171 echo "usage: $0 [-j N|--jobs N] [--only TEST]" >&2
172 exit 1
173 ;;
174 esac
175done
176if ! [[ "$JOBS" =~ ^[0-9]+$ ]] || [ "$JOBS" -lt 1 ]; then
177 echo "error: --jobs must be a positive integer (got '$JOBS')" >&2
178 exit 1
179fi
180
181pick_free_port() {
182 local port
183 for _ in $(seq 1 50); do
184 port=$(((RANDOM % 16384) + 20000))
185 if ! (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
186 echo "$port"
187 return 0
188 fi
189 done
190 echo "error: could not find a free port for the cache" >&2
191 return 1
192}
193
194SUCCESS=0
195rm -rf /tmp/test-spindle-microvm-logs
196
197log "setup local cache & temp environment"
198TEMP_DIR=$(mktemp -d -t test-spindle-microvm-XXXXXX)
199
200if [ ! -e /dev/vsock ]; then
201 echo "error: /dev/vsock is missing; run sudo modprobe vhost_vsock" >&2
202 exit 1
203fi
204
205log "build spindle & microvm image tarball"
206nix develop --command go build -o spindle/spindle-microvm-run ./cmd/spindle-microvm-run
207TARBALL_PATH=$(nix build .#spindle-nixos-image-tarball --no-link --print-out-paths)
208mkdir -p "$TEMP_DIR/image"
209tar -C "$TEMP_DIR/image" -xzf "$TARBALL_PATH"
210IMAGE_SPEC_JSON="$TEMP_DIR/image/spec.json"
211
212log "build alpine microvm image tarball"
213ALPINE_TARBALL_PATH=$(nix build .#spindle-alpine-image-tarball --no-link --print-out-paths)
214mkdir -p "$TEMP_DIR/alpine-image"
215tar -C "$TEMP_DIR/alpine-image" -xzf "$ALPINE_TARBALL_PATH"
216ALPINE_IMAGE_SPEC_JSON="$TEMP_DIR/alpine-image/spec.json"
217
218kill_temp_dir_procs() {
219 if [ -f "$TEMP_DIR/ncps.pid" ]; then
220 kill "$(cat "$TEMP_DIR/ncps.pid")" 2>/dev/null || true
221 fi
222 pkill -TERM -f "$TEMP_DIR" 2>/dev/null || true
223 local i
224 for i in $(seq 1 20); do
225 pgrep -f "$TEMP_DIR" >/dev/null 2>&1 || break
226 sleep 0.25
227 done
228 pkill -KILL -f "$TEMP_DIR" 2>/dev/null || true
229}
230
231collect_logs() {
232 echo "test failed. copying logs to /tmp/test-spindle-microvm-logs"
233 mkdir -p /tmp/test-spindle-microvm-logs
234 local f
235 for f in "$TEMP_DIR"/*; do
236 [ -f "$f" ] && cp "$f" /tmp/test-spindle-microvm-logs/
237 done
238 local work logf
239 for work in "$TEMP_DIR"/work-*; do
240 [ -d "$work" ] || continue
241 for logf in "$work"/*.log; do
242 [ -f "$logf" ] || continue
243 strip_ansi "$logf" > "/tmp/test-spindle-microvm-logs/$(basename "$work")-$(basename "$logf")"
244 done
245 done
246}
247
248CLEANED=0
249cleanup() {
250 [ "$CLEANED" -eq 1 ] && return
251 CLEANED=1
252
253 print_summary
254 log "cleaning up..."
255
256 local jobs_pids
257 jobs_pids=$(jobs -p)
258 [ -n "$jobs_pids" ] && kill $jobs_pids 2>/dev/null || true
259
260 kill_temp_dir_procs
261
262 [ "$SUCCESS" -ne 1 ] && collect_logs
263
264 chmod -R +w "$TEMP_DIR" 2>/dev/null || true
265 rm -rf "$TEMP_DIR"
266 echo "done"
267}
268trap cleanup EXIT
269# route signals through the EXIT trap so an interrupt still tears down VMs.
270trap 'exit 130' INT
271trap 'exit 143' TERM
272
273CACHE_PORT=$(pick_free_port)
274./spindle/engines/microvm/start-test-cache.sh "$TEMP_DIR" "$CACHE_PORT"
275source "$TEMP_DIR/env.sh"
276
277run_vm() {
278 local name=""
279 local timeout="60s"
280 local upload=0
281 local upload_url=""
282 local activate=""
283 local no_cache=0
284 local db=""
285 local spec="$IMAGE_SPEC_JSON"
286
287 while [[ $# -gt 0 ]]; do
288 case "$1" in
289 --spec)
290 spec="$2"
291 shift 2
292 ;;
293 --name)
294 name="$2"
295 shift 2
296 ;;
297 --timeout)
298 timeout="$2"
299 shift 2
300 ;;
301 --upload)
302 upload=1
303 shift
304 ;;
305 --upload-url)
306 upload=1
307 upload_url="$2"
308 shift 2
309 ;;
310 --activate)
311 activate="$2"
312 shift 2
313 ;;
314 --no-cache)
315 no_cache=1
316 shift
317 ;;
318 --db)
319 db="$2"
320 shift 2
321 ;;
322 --)
323 shift
324 break
325 ;;
326 *)
327 echo "unknown argument: $1" >&2
328 exit 1
329 ;;
330 esac
331 done
332
333 local work_dir="$TEMP_DIR/work-${name}"
334 mkdir -p "$work_dir"
335
336 local args=(
337 --image-spec "$spec"
338 --work-dir "$work_dir"
339 --exec-timeout "$timeout"
340 --port "${SPINDLE_TEST_VSOCK_PORT:-10240}"
341 --memory-mib 4096
342 )
343
344 if [ "$no_cache" -eq 0 ]; then
345 args+=(
346 --cache-read-url "$CACHE_URL"
347 --cache-trusted-public-key "$CACHE_PUBKEY"
348 )
349 fi
350
351 if [ "$upload" -eq 1 ]; then
352 if [ -z "$upload_url" ]; then
353 upload_url="$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH"
354 fi
355 args+=(
356 --cache-upload-url "$upload_url"
357 )
358 fi
359
360 if [ -n "$activate" ]; then
361 args+=(
362 --activate-config "$activate"
363 )
364 fi
365
366 if [ -n "$db" ]; then
367 args+=(
368 --db "$db"
369 )
370 fi
371
372 local out
373 if ! out=$(spindle/spindle-microvm-run "${args[@]}" -- "$@" 2>&1); then
374 echo "$out" | strip_ansi >&2
375 strip_ansi "$work_dir/serial.log" >&2
376 strip_ansi "$work_dir/qemu.log" >&2
377 return 1
378 fi
379 echo "$out"
380}
381
382run_test_job() {
383 local name="$1"
384 local func="$2"
385 local port="$3"
386 export SPINDLE_TEST_VSOCK_PORT="$port"
387
388 local logfile="$TEMP_DIR/test-${name}.log"
389 local start
390 start=$(get_time_ms)
391 log "[$name] start (vsock port $port)"
392
393 local status="Passed"
394 if "$func" > "$logfile" 2>&1; then
395 status="Passed"
396 else
397 local rc=$?
398 if [ "$rc" -eq "$SKIP_TEST_RC" ]; then
399 status="Skipped"
400 else
401 status="Failed"
402 fi
403 fi
404
405 local duration_ms=$(($(get_time_ms) - start))
406 printf '%s\t%s\n' "$status" "$duration_ms" > "$TEMP_DIR/test-${name}.status"
407
408 local duration_str
409 duration_str=$(format_duration "$duration_ms")
410 if [ "$status" = "Failed" ]; then
411 printf "\n\033[0;31m>>> [%s] FAILED (%s)\033[0m\n" "$name" "$duration_str"
412 strip_ansi "$logfile" || true
413 elif [ "$status" = "Skipped" ]; then
414 printf "\n\033[0;33m>>> [%s] skipped (%s)\033[0m\n" "$name" "$duration_str"
415 strip_ansi "$logfile" || true
416 else
417 printf "\n\033[0;32m>>> [%s] passed (%s)\033[0m\n" "$name" "$duration_str"
418 fi
419}
420
421# schedules every selected test across at most $JOBS concurrent VMs (each on its
422# own vsock port), then aggregates the per-test status files into the summary
423# arrays. returns 1 if any test failed, 0 otherwise.
424run_tests() {
425 local base_port=10240
426 local idx=0
427 local running=0
428
429 for func in "${TESTS[@]}"; do
430 local name="${func#test_}"
431 name="${name//_/-}"
432 if [ -n "${TEST_ONLY:-}" ] && [ "${TEST_ONLY}" != "$name" ]; then
433 continue
434 fi
435
436 run_test_job "$name" "$func" "$((base_port + idx))" &
437 idx=$((idx + 1))
438 running=$((running + 1))
439
440 if [ "$running" -ge "$JOBS" ]; then
441 wait -n || true
442 running=$((running - 1))
443 fi
444 done
445 wait
446
447 local failed=0
448 for func in "${TESTS[@]}"; do
449 local name="${func#test_}"
450 name="${name//_/-}"
451 if [ -n "${TEST_ONLY:-}" ] && [ "${TEST_ONLY}" != "$name" ]; then
452 continue
453 fi
454
455 local statusfile="$TEMP_DIR/test-${name}.status"
456 if [ ! -f "$statusfile" ]; then
457 TEST_NAMES+=("$name")
458 TEST_STATUSES+=("Failed")
459 TEST_TIMES+=(0)
460 failed=1
461 continue
462 fi
463
464 local status duration_ms
465 IFS=$'\t' read -r status duration_ms < "$statusfile"
466 TEST_NAMES+=("$name")
467 TEST_STATUSES+=("$status")
468 TEST_TIMES+=("$duration_ms")
469 if [ "$status" = "Failed" ]; then
470 failed=1
471 fi
472 done
473
474 return "$failed"
475}
476
477test_realize() {
478 local test_store_path
479 test_store_path=$(nix-build -E 'with import <nixpkgs> {}; writeText "test-file" "hello from cache"' --no-out-link)
480 nix copy --to "$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH" "$test_store_path"
481
482 local out
483 out=$(run_vm --name "realize" --timeout "60s" -- /run/current-system/sw/bin/bash -lc '
484set -euo pipefail
485store_path=$1
486cache_url=$(sed -n "s/^extra-substituters = //p" /run/spindle/nix.conf)
487cache_url=${cache_url%% *}
488if [ -z "$cache_url" ]; then
489 echo "error: cache URL not found in /run/spindle/nix.conf" >&2
490 exit 1
491fi
492
493http_version=$(/run/current-system/sw/bin/curl --http2-prior-knowledge -fsS -o /dev/null -w "%{http_version}" "$cache_url/nix-cache-info")
494echo "http_version=$http_version"
495case "$http_version" in
496 2|2.0) ;;
497 *)
498 echo "error: cache proxy did not negotiate HTTP/2 (got $http_version)" >&2
499 exit 1
500 ;;
501esac
502
503if [ "$(stat -c %d /tmp)" = "$(stat -c %d /workspace)" ]; then
504 echo "tmp_on_persist=yes"
505else
506 echo "tmp_on_persist=no"
507fi
508
509/run/current-system/sw/bin/nix-store --realise "$store_path" >/dev/null
510' bash "$test_store_path") || return 1
511
512 check_needles "$out" "^http_version=2(\\.0)?$" "^tmp_on_persist=yes$" || return 1
513 echo "success: store path realized from cache and cache proxy accepted cleartext HTTP/2"
514}
515
516test_build_upload() {
517 local nix_expr='with import <nixpkgs> {}; writeText "uploaded-test-file" "hello from vm upload"'
518 local out
519 out=$(run_vm --name "build-upload" --timeout "120s" --upload -- /run/current-system/sw/bin/bash -l -c "nix-build -E '$nix_expr' --no-out-link") || return 1
520
521 local built_path
522 built_path=$(echo "$out" | strip_ansi | grep -v '\.drv' | grep -o '/nix/store/[a-z0-9]*-uploaded-test-file' | head -n 1 || true)
523 if [ -z "$built_path" ]; then
524 echo "error: could not find built store path in vm output" >&2
525 return 1
526 fi
527 echo "extracted path: $built_path"
528
529 local hash
530 hash=$(basename "$built_path" | cut -d'-' -f1)
531 if ! curl -s -f "$CACHE_URL/${hash}.narinfo" > /dev/null; then
532 echo "error: built store path was not uploaded to the binary cache" >&2
533 return 1
534 fi
535 echo "success: store path uploaded to cache"
536}
537
538test_ssh_store_upload() {
539 if ! host_is_nixos; then
540 skip_test "ssh store upload smoke only runs on nixos hosts"
541 fi
542
543 run_ssh_store_upload_case() {
544 local target="$1"
545 local label="$2"
546 local name="uploaded-test-file-${label}"
547 local content="hello from vm upload via ${label}"
548 local out
549 out=$(run_vm --name "$label" --timeout "180s" --upload-url "$target" -- /run/current-system/sw/bin/bash -l -c "nix-build -E 'with import <nixpkgs> {}; writeText \"$name\" \"$content\"' --no-out-link") || return 1
550
551 local built_path
552 built_path=$(echo "$out" | strip_ansi | grep -v '\.drv' | grep -o "/nix/store/[a-z0-9]*-${name}" | head -n 1 || true)
553 if [ -z "$built_path" ]; then
554 echo "error: could not find built store path in vm output for $label" >&2
555 echo "$out" | strip_ansi >&2
556 return 1
557 fi
558 if [ ! -e "$built_path" ]; then
559 echo "error: uploaded path missing from host store for $label: $built_path" >&2
560 return 1
561 fi
562 if [ "$(cat "$built_path")" != "$content" ]; then
563 echo "error: uploaded path content mismatch for $label" >&2
564 echo "path=$built_path" >&2
565 return 1
566 fi
567 echo "success: store path uploaded to $target via spindle"
568 }
569
570 run_ssh_store_upload_case "ssh-ng://localhost" "ssh-ng-upload"
571 run_ssh_store_upload_case "ssh://localhost" "ssh-upload"
572}
573
574test_networking() {
575 local hello_path
576 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
577
578 local out
579 out=$(run_vm --name "networking" --timeout "120s" --no-cache -- /run/current-system/sw/bin/bash -c "/run/current-system/sw/bin/curl -I --connect-timeout 1 -m 1 http://10.0.2.2:$CACHE_PORT; /run/current-system/sw/bin/nix-store --realise $hello_path") || return 1
580
581 if echo "$out" | grep -qi -E "unreachable|timeout|failed to connect|timed out" || echo "$out" | grep -q "exited with code"; then
582 echo "success: host network access blocked"
583 else
584 echo "error: guest vm accessed host network or returned unexpected output" >&2
585 echo "$out" | strip_ansi >&2
586 return 1
587 fi
588 echo "success: guest vm reached the internet and substituted hello"
589}
590
591test_substitution_and_no_upload() {
592 local hello_path
593 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
594
595 local out
596 out=$(run_vm --name "nixpkgs-hello" --timeout "180s" --upload -- /run/current-system/sw/bin/nix-store --realise "$hello_path") || return 1
597
598 # substituted from our proxy, and nothing uploaded back to the cache.
599 check_needles "$out" "copying path.*hello" "cache uploaded: 0" || return 1
600 echo "success: hello package substituted from upstream cache and was not uploaded"
601}
602
603# a pinned registry, reused by the dependency and registry-pin tests.
604ACTIVATION_REGISTRY='"registry": {
605 "nixpkgs": "github:nixos/nixpkgs/nixos-unstable",
606 "my-nixpkgs": "nixpkgs"
607 }'
608
609test_activation_services() {
610 local config='{
611 "services": {
612 "openssh": {
613 "enable": true,
614 "authorizedKeysFiles": ["/etc/ssh/authorized_keys"]
615 }
616 }
617 }'
618 local out
619 out=$(run_vm --name "activation-services" --timeout "300s" --activate "$config" -- /run/current-system/sw/bin/systemctl is-active sshd) || return 1
620 check_needles "$out" "^active$" || return 1
621 echo "success: openssh service active after activation"
622}
623
624test_activation_dependencies() {
625 # pkg-config + openssl as bare dependencies (resolved via the pinned nixpkgs
626 # registry); their job is to prove the activation devshell exports build env
627 # vars like PKG_CONFIG_PATH, not just PATH. hello comes in via the github
628 # flakeref and (separately) the my-nixpkgs alias.
629 local config='{
630 '"$ACTIVATION_REGISTRY"',
631 "dependencies": [
632 "pkg-config",
633 "openssl",
634 "github:nixos/nixpkgs#hello",
635 "my-nixpkgs#hello"
636 ]
637 }'
638 # dependencies live in the activation devshell now, not systemPackages, so a
639 # step picks them up by sourcing the materialised env (this is what the
640 # engine's RunStep does automatically; the CLI runner does not, so we do it
641 # here). this script is reused across the build and cache-hit runs below.
642 local job='
643env_file=/run/spindle/devshell-env.sh
644[ -f "$env_file" ] && echo "env_file=present" || echo "env_file=missing"
645echo "shuttle_env=$(systemctl show shuttle -p Environment --value)"
646echo "step_xdg_cache_home=${XDG_CACHE_HOME:-unset}"
647mkdir -p "$XDG_CACHE_HOME/go-build"
648[ -w "$XDG_CACHE_HOME/go-build" ] && echo "go_build_cache_writable=yes"
649if [ -d /var/cache/shuttle ] && [ "$(stat -c %d /var/cache/shuttle)" = "$(stat -c %d /workspace)" ]; then
650 echo "shuttle_cache_on_persist=yes"
651else
652 echo "shuttle_cache_on_persist=no"
653fi
654if find /var/cache/shuttle/nix -mindepth 1 -print -quit 2>/dev/null | grep -q .; then
655 echo "shuttle_nix_cache_written=yes"
656else
657 echo "shuttle_nix_cache_written=no"
658fi
659
660
661
662# mirror RunStep
663. "$env_file"
664export PATH="$PATH:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin"
665
666set -euo pipefail
667# bare deps: pkg-config must be on PATH, and it must locate openssl through the
668# devshell-provided PKG_CONFIG_PATH (openssls headers live in a separate `dev`
669# output, so this also proves that output got realised into the devshell).
670echo "pkgconfig=$(pkg-config --version)"
671pkg-config --exists openssl && echo "openssl_found=yes"
672echo "openssl_version=$(pkg-config --modversion openssl)"
673inc=$(pkg-config --variable=includedir openssl)
674echo "includedir=$inc"
675[ -f "$inc/openssl/ssl.h" ] && echo "dev_headers=found"
676
677# flakeref + aliased deps
678echo "hello=$(hello)"
679'
680
681 local db_path="$TEMP_DIR/activation-dependencies.db"
682
683 # first run: build the config, materialise the devshell env profile, upload
684 # its closure, and record both toplevel + env profile in the db.
685 local out
686 out=$(run_vm --name "activation-dependencies" --timeout "600s" --activate "$config" --db "$db_path" --upload -- /run/current-system/sw/bin/bash -l -c "$job") || return 1
687
688 check_needles "$out" \
689 "env_file=present" "shuttle_env=.*XDG_CACHE_HOME=/var/cache/shuttle" \
690 "step_xdg_cache_home=/workspace/.cache" "go_build_cache_writable=yes" \
691 "shuttle_cache_on_persist=yes" "shuttle_nix_cache_written=yes" \
692 "pkgconfig=[0-9]" "openssl_found=yes" "openssl_version=[0-9]" \
693 "dev_headers=found" "hello=Hello, world!" || return 1
694 echo "success: bare (pkg-config + openssl, dev headers found via PKG_CONFIG_PATH), flakeref, and aliased deps all resolved"
695
696 # second run: same config + db, no upload. the devshell .drv is absent (no
697 # eval happens on a cache hit), so the env must be re-read from the cached
698 # env profile. the deps must still resolve exactly as on the build run.
699 out=$(run_vm --name "activation-dependencies-cached" --timeout "300s" --activate "$config" --db "$db_path" -- /run/current-system/sw/bin/bash -l -c "$job") || return 1
700
701 check_needles "$out" \
702 "realizing cached NixOS config" \
703 "env_file=present" "shuttle_env=.*XDG_CACHE_HOME=/var/cache/shuttle" \
704 "step_xdg_cache_home=/workspace/.cache" "go_build_cache_writable=yes" \
705 "shuttle_cache_on_persist=yes" "pkgconfig=[0-9]" "openssl_found=yes" \
706 "openssl_version=[0-9]" "dev_headers=found" "hello=Hello, world!" || return 1
707 echo "success: cache-hit run re-read the devshell env from the cached profile (no drv) and all deps resolved"
708}
709
710test_activation_registry_pin() {
711 # the nixpkgs the image itself was built from; the registry override must NOT
712 # resolve to this. deterministic (locked in the repo flake), so safe to compare.
713 local base_nixpkgs
714 base_nixpkgs=$(nix eval --raw --impure --expr '(builtins.getFlake (toString ./.)).inputs.nixpkgs.outPath')
715
716 local config='{
717 '"$ACTIVATION_REGISTRY"'
718 }'
719 # the pinned nixpkgs must reach the system nix config: resolving it via the
720 # flakes CLI must not error "is not locked", and it must win the <nixpkgs> nixPath.
721 local out
722 out=$(run_vm --name "activation-registry-pin" --timeout "300s" --activate "$config" -- /run/current-system/sw/bin/bash -l -c '
723set -euo pipefail
724echo "lib_version=$(nix eval --raw nixpkgs#lib.version)"
725echo "nix_path=$(nix eval --raw --impure --expr "toString <nixpkgs>")"
726') || return 1
727
728 check_needles "$out" "lib_version=[0-9]" || return 1
729 local clean guest_nixpath
730 clean=$(echo "$out" | strip_ansi)
731 guest_nixpath=$(echo "$clean" | sed -n 's/^nix_path=//p' | head -n1)
732 if [ -z "$guest_nixpath" ] || [ "$guest_nixpath" = "$base_nixpkgs" ]; then
733 echo "error: guest <nixpkgs> nixPath did not resolve to the registry override (got '$guest_nixpath', base '$base_nixpkgs')" >&2
734 return 1
735 fi
736 echo "success: pinned nixpkgs registry reached the guest nix config (flakes CLI + <nixpkgs> nixPath)"
737}
738
739test_activation_cache_substitution() {
740 # a unique path that only exists in the configured (workflow) cache; the host
741 # seeds it so the guest can prove it substitutes through the read proxy.
742 local test_store_path
743 test_store_path=$(nix-build -E 'with import <nixpkgs> {}; writeText "activation-cache-test" "hello from the workflow cache"' --no-out-link)
744 nix copy --to "$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH" "$test_store_path"
745
746 # a trivial config: this test only cares that the read proxy serves the
747 # workflow-cache path during an activated run.
748 local out
749 out=$(run_vm --name "activation-cache-substitution" --timeout "300s" --activate '{}' -- /run/current-system/sw/bin/bash -l -c '
750set -euo pipefail
751store_path=$1
752# the unique path only exists in the configured cache, so realising it proves it
753# was substituted through the read proxy and not built or found elsewhere.
754nix-store --realise "$store_path" >/dev/null
755echo "substituted=$(cat "$store_path")"
756' bash "$test_store_path") || return 1
757
758 check_needles "$out" "substituted=hello from the workflow cache" || return 1
759 echo "success: unique path substituted from the workflow cache through the read proxy"
760}
761
762test_activation_docker() {
763 local config='{
764 "virtualisation": {
765 "docker": { "enable": true }
766 }
767 }'
768 # docker.service is up, but the daemon socket can lag a beat behind activation;
769 # wait for it to answer, then pull+run a real image. this drives the slimmed
770 # kernel modules: overlay.ko storage plus bridge/iptables networking out of the
771 # pruned tree, with outbound DNS/network over the guest slirp link.
772 local out
773 out=$(run_vm --name "activation-docker" --timeout "600s" --activate "$config" -- /run/current-system/sw/bin/bash -l -c '
774set -euo pipefail
775echo "docker_unit=$(systemctl is-active docker)"
776for i in $(seq 1 60); do docker info >/dev/null 2>&1 && break; sleep 1; done
777docker info >/dev/null
778echo "storage_driver=$(docker info --format "{{.Driver}}")"
779docker run --rm alpine cat /etc/alpine-release | sed "s/^/alpine_release=/"
780docker run --rm alpine echo container-ran-ok
781') || return 1
782
783 check_needles "$out" \
784 "^docker_unit=active$" "storage_driver=overlay(2|fs)" \
785 "alpine_release=[0-9]+\." "container-ran-ok" || return 1
786 echo "success: docker service active, pulled and ran an alpine container on the overlay storage driver"
787}
788
789test_activation_cached_realize() {
790 local config='{
791 "services": {
792 "openssh": {
793 "enable": true,
794 "authorizedKeysFiles": ["/etc/ssh/authorized_keys"]
795 }
796 }
797 }'
798 local db_path="$TEMP_DIR/activation-cached.db"
799
800 # first run: build the config, upload its closure, and record the toplevel in
801 # the db. nothing cached yet, so this builds from scratch.
802 local out
803 out=$(run_vm --name "activation-cached-first" --timeout "600s" --activate "$config" --db "$db_path" --upload -- /run/current-system/sw/bin/systemctl is-active sshd) || return 1
804 check_needles "$out" "^active$" || return 1
805
806 # second run: same config + db, no upload. must realize the recorded toplevel
807 # from the cache instead of rebuilding, and the cached system must come up.
808 out=$(run_vm --name "activation-cached-second" --timeout "300s" --activate "$config" --db "$db_path" -- /run/current-system/sw/bin/systemctl is-active sshd) || return 1
809
810 check_needles "$out" "realizing cached NixOS config" "^active$" || return 1
811 echo "success: second run realized the cached NixOS config from the cache and sshd came up"
812}
813
814test_alpine() {
815 local hello_path
816 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
817
818 local out
819 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine" --timeout "180s" --no-cache -- /bin/sh -lc '
820set -eu
821export HOME=/workspace
822hello_path=$1
823echo "release=$(cat /etc/alpine-release)"
824echo "user=$(id -un)"
825git version
826bash -c "echo bash=\$BASH_VERSION"
827touch /workspace/write-test
828echo "workspace writable"
829git ls-remote https://tangled.org/@tangled.org/core HEAD >/dev/null
830echo "git over https ok"
831apk add make
832echo "apk ok"
833# substitute a real package from cache.nixos.org over HTTPS and run it
834nix-store --realise "$hello_path" >/dev/null
835echo "ran=$("$hello_path/bin/hello")"
836' sh "$hello_path") || return 1
837
838 check_needles "$out" \
839 "release=" "user=spindle-workflow" "git version" "bash=" \
840 "workspace writable" "git over https ok" "apk ok" "ran=Hello, world!" || return 1
841 echo "success: alpine guest booted, ran as workflow user, wrote workspace, cloned + installed over the network, and substituted+ran a package from cache.nixos.org over HTTPS"
842}
843
844test_alpine_podman() {
845 # install podman via apk and run a real container as the workflow user.
846 # rootless podman lives entirely in the writable workspace (storage + runroot
847 # under XDG dirs there), uses podman's default storage driver, and pulls over
848 # the guest network like the other alpine tests.
849 local out
850 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine-podman" --timeout "300s" --no-cache -- /bin/sh -lc '
851set -eu
852# no env setup here on purpose: shuttle seeds USER/LOGNAME/HOME/SHELL from the
853# workflow users passwd entry and provisions XDG_RUNTIME_DIR, so rootless podman
854# works out of the box. asserting those below doubles as a check on that.
855
856# shadow-uidmap ships newuidmap/newgidmap which rootless podman uses to apply the
857# /etc/subuid + /etc/subgid ranges baked into the image.
858apk add podman shadow-uidmap
859echo "user=$(id -un) USER=$USER HOME=$HOME XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
860echo "newuidmap=$(command -v newuidmap)"
861echo "podman_version=$(podman --version)"
862
863podman info >/dev/null
864echo "storage_driver=$(podman info --format "{{.Store.GraphDriverName}}")"
865
866podman run --rm --network=host docker.io/library/alpine cat /etc/alpine-release | sed "s/^/container_release=/"
867podman run --rm --network=host docker.io/library/alpine echo container-ran-ok
868' sh) || return 1
869
870 check_needles "$out" \
871 "user=spindle-workflow USER=spindle-workflow HOME=/workspace XDG_RUNTIME_DIR=/run/user/970" \
872 "newuidmap=/" "podman_version=" \
873 "storage_driver=" "container_release=[0-9]+\." "container-ran-ok" || return 1
874 echo "success: alpine guest installed podman via apk and pulled + ran a rootless container"
875}
876
877# asserts a store path's narinfo shows up in the local cache, retrying briefly
878# since the post-build-hook enqueues uploads asynchronously.
879cache_has_path() {
880 local path="$1"
881 local hash
882 hash=$(basename "$path" | cut -d'-' -f1)
883 local i
884 for i in $(seq 1 20); do
885 if curl -s -f "$CACHE_URL/${hash}.narinfo" > /dev/null; then
886 return 0
887 fi
888 sleep 0.5
889 done
890 return 1
891}
892
893test_alpine_nix() {
894 local test_store_path
895 test_store_path=$(nix-build -E 'with import <nixpkgs> {}; writeText "alpine-nix-test" "hello from cache to alpine"' --no-out-link)
896 nix copy --to "$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH" "$test_store_path"
897
898 # exercise the full local-cache path: daemon connectivity, substitution,
899 # store-db queries, and a build via *both* the classic (nix-build) and the
900 # new flakes/nix-command (nix build) frontends. the two build derivations
901 # use distinct names so we can confirm each got uploaded back to the cache.
902 local out
903 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine-nix" --timeout "180s" --upload -- /bin/sh -lc '
904set -eu
905export HOME=/workspace
906store_path=$1
907
908echo "nix_version=$(nix --version | head -n1)"
909{ nix store info >/dev/null 2>&1 || nix store ping >/dev/null 2>&1; } && echo "daemon=ok"
910
911# substitute a path from the cache and query the store db about it
912nix-store --realise "$store_path" >/dev/null
913echo "substituted=$(cat "$store_path")"
914echo "requisites=$(nix-store --query --requisites "$store_path" | wc -l | tr -d " ")"
915nix path-info --json "$store_path" >/dev/null && echo "path_info=ok"
916
917# build via the new CLI; the substituted path is declared as a real input
918# (builtins.storePath) so nix must realise it into the build sandbox first.
919# heredoc is unquoted (the outer guest script is single-quoted, so a quoted
920# delimiter would close it), hence \$ escapes what nix/the builder must expand.
921export DEP="$store_path"
922cat > /workspace/new.nix <<NIXEOF
923let dep = builtins.storePath (builtins.getEnv "DEP"); in
924derivation {
925 name = "alpine-nix-build-new";
926 system = "x86_64-linux";
927 builder = "/bin/sh";
928 # the sandbox only provides the sh builtin shell (no coreutils in PATH), so
929 # stick to builtins: the build only succeeds if nix realised the declared
930 # storePath dependency into the sandbox, where [ -r ] can see it.
931 args = [ "-c" "[ -r \${dep} ] && echo via-nix-build-with-dep > \$out" ];
932}
933NIXEOF
934new_path=$(nix build --impure --file /workspace/new.nix --no-link --print-out-paths)
935echo "new_path=$new_path"
936echo "new_content=$(tr "\n" "|" < "$new_path")"
937
938# build via the classic CLI
939cat > /workspace/old.nix <<NIXEOF
940derivation {
941 name = "alpine-nix-build-old";
942 system = "x86_64-linux";
943 builder = "/bin/sh";
944 args = [ "-c" "echo built-on-alpine > \$out" ];
945}
946NIXEOF
947old_path=$(nix-build /workspace/old.nix --no-out-link)
948echo "old_path=$old_path"
949' sh "$test_store_path") || return 1
950
951 check_needles "$out" \
952 "daemon=ok" "substituted=hello from cache to alpine" "path_info=ok" \
953 "requisites=[1-9][0-9]*" "new_content=via-nix-build-with-dep" || return 1
954
955 local clean new_path old_path
956 clean=$(echo "$out" | strip_ansi)
957 new_path=$(echo "$clean" | grep -o 'new_path=/nix/store/[a-z0-9]*-alpine-nix-build-new' | cut -d= -f2)
958 old_path=$(echo "$clean" | grep -o 'old_path=/nix/store/[a-z0-9]*-alpine-nix-build-old' | cut -d= -f2)
959 if [ -z "$new_path" ] || [ -z "$old_path" ]; then
960 echo "error: could not extract both built store paths from alpine guest output" >&2
961 return 1
962 fi
963 if ! cache_has_path "$new_path"; then
964 echo "error: nix-build (new CLI) output was not uploaded to the cache" >&2
965 return 1
966 fi
967 if ! cache_has_path "$old_path"; then
968 echo "error: nix-build (classic CLI) output was not uploaded to the cache" >&2
969 return 1
970 fi
971 echo "success: alpine guest substituted, queried the store db, built via both CLIs, and uploaded both outputs"
972}
973
974TESTS=(
975 test_alpine
976 test_alpine_nix
977 test_alpine_podman
978 test_realize
979 test_build_upload
980 test_ssh_store_upload
981 test_networking
982 test_substitution_and_no_upload
983 test_activation_services
984 test_activation_dependencies
985 test_activation_registry_pin
986 test_activation_cache_substitution
987 test_activation_docker
988 test_activation_cached_realize
989)
990
991log "running ${#TESTS[@]} tests"
992if ! run_tests; then
993 exit 1
994fi
995
996SUCCESS=1
997log "passed!!"