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
503/run/current-system/sw/bin/nix-store --realise "$store_path" >/dev/null
504' bash "$test_store_path") || return 1
505
506 check_needles "$out" "^http_version=2(\\.0)?$" || return 1
507 echo "success: store path realized from cache and cache proxy accepted cleartext HTTP/2"
508}
509
510test_build_upload() {
511 local nix_expr='with import <nixpkgs> {}; writeText "uploaded-test-file" "hello from vm upload"'
512 local out
513 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
514
515 local built_path
516 built_path=$(echo "$out" | strip_ansi | grep -v '\.drv' | grep -o '/nix/store/[a-z0-9]*-uploaded-test-file' | head -n 1 || true)
517 if [ -z "$built_path" ]; then
518 echo "error: could not find built store path in vm output" >&2
519 return 1
520 fi
521 echo "extracted path: $built_path"
522
523 local hash
524 hash=$(basename "$built_path" | cut -d'-' -f1)
525 if ! curl -s -f "$CACHE_URL/${hash}.narinfo" > /dev/null; then
526 echo "error: built store path was not uploaded to the binary cache" >&2
527 return 1
528 fi
529 echo "success: store path uploaded to cache"
530}
531
532test_ssh_store_upload() {
533 if ! host_is_nixos; then
534 skip_test "ssh store upload smoke only runs on nixos hosts"
535 fi
536
537 run_ssh_store_upload_case() {
538 local target="$1"
539 local label="$2"
540 local name="uploaded-test-file-${label}"
541 local content="hello from vm upload via ${label}"
542 local out
543 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
544
545 local built_path
546 built_path=$(echo "$out" | strip_ansi | grep -v '\.drv' | grep -o "/nix/store/[a-z0-9]*-${name}" | head -n 1 || true)
547 if [ -z "$built_path" ]; then
548 echo "error: could not find built store path in vm output for $label" >&2
549 echo "$out" | strip_ansi >&2
550 return 1
551 fi
552 if [ ! -e "$built_path" ]; then
553 echo "error: uploaded path missing from host store for $label: $built_path" >&2
554 return 1
555 fi
556 if [ "$(cat "$built_path")" != "$content" ]; then
557 echo "error: uploaded path content mismatch for $label" >&2
558 echo "path=$built_path" >&2
559 return 1
560 fi
561 echo "success: store path uploaded to $target via spindle"
562 }
563
564 run_ssh_store_upload_case "ssh-ng://localhost" "ssh-ng-upload"
565 run_ssh_store_upload_case "ssh://localhost" "ssh-upload"
566}
567
568test_networking() {
569 local hello_path
570 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
571
572 local out
573 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
574
575 if echo "$out" | grep -qi -E "unreachable|timeout|failed to connect|timed out" || echo "$out" | grep -q "exited with code"; then
576 echo "success: host network access blocked"
577 else
578 echo "error: guest vm accessed host network or returned unexpected output" >&2
579 echo "$out" | strip_ansi >&2
580 return 1
581 fi
582 echo "success: guest vm reached the internet and substituted hello"
583}
584
585test_substitution_and_no_upload() {
586 local hello_path
587 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
588
589 local out
590 out=$(run_vm --name "nixpkgs-hello" --timeout "180s" --upload -- /run/current-system/sw/bin/nix-store --realise "$hello_path") || return 1
591
592 # substituted from our proxy, and nothing uploaded back to the cache.
593 check_needles "$out" "copying path.*hello" "cache uploaded: 0" || return 1
594 echo "success: hello package substituted from upstream cache and was not uploaded"
595}
596
597# a pinned registry, reused by the dependency and registry-pin tests.
598ACTIVATION_REGISTRY='"registry": {
599 "nixpkgs": "github:nixos/nixpkgs/nixos-unstable",
600 "my-nixpkgs": "nixpkgs"
601 }'
602
603test_activation_services() {
604 local config='{
605 "services": {
606 "openssh": {
607 "enable": true,
608 "authorizedKeysFiles": ["/etc/ssh/authorized_keys"]
609 }
610 }
611 }'
612 local out
613 out=$(run_vm --name "activation-services" --timeout "300s" --activate "$config" -- /run/current-system/sw/bin/systemctl is-active sshd) || return 1
614 check_needles "$out" "^active$" || return 1
615 echo "success: openssh service active after activation"
616}
617
618test_activation_dependencies() {
619 # pkg-config + openssl as bare dependencies (resolved via the pinned nixpkgs
620 # registry); their job is to prove the activation devshell exports build env
621 # vars like PKG_CONFIG_PATH, not just PATH. hello comes in via the github
622 # flakeref and (separately) the my-nixpkgs alias.
623 local config='{
624 '"$ACTIVATION_REGISTRY"',
625 "dependencies": [
626 "pkg-config",
627 "openssl",
628 "github:nixos/nixpkgs#hello",
629 "my-nixpkgs#hello"
630 ]
631 }'
632 # dependencies live in the activation devshell now, not systemPackages, so a
633 # step picks them up by sourcing the materialised env (this is what the
634 # engine's RunStep does automatically; the CLI runner does not, so we do it
635 # here). this script is reused across the build and cache-hit runs below.
636 local job='
637env_file=/run/spindle/devshell-env.sh
638[ -f "$env_file" ] && echo "env_file=present" || echo "env_file=missing"
639
640# mirror RunStep
641. "$env_file"
642export PATH="$PATH:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin"
643
644set -euo pipefail
645# bare deps: pkg-config must be on PATH, and it must locate openssl through the
646# devshell-provided PKG_CONFIG_PATH (openssls headers live in a separate `dev`
647# output, so this also proves that output got realised into the devshell).
648echo "pkgconfig=$(pkg-config --version)"
649pkg-config --exists openssl && echo "openssl_found=yes"
650echo "openssl_version=$(pkg-config --modversion openssl)"
651inc=$(pkg-config --variable=includedir openssl)
652echo "includedir=$inc"
653[ -f "$inc/openssl/ssl.h" ] && echo "dev_headers=found"
654
655# flakeref + aliased deps
656echo "hello=$(hello)"
657'
658
659 local db_path="$TEMP_DIR/activation-dependencies.db"
660
661 # first run: build the config, materialise the devshell env profile, upload
662 # its closure, and record both toplevel + env profile in the db.
663 local out
664 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
665
666 check_needles "$out" \
667 "env_file=present" "pkgconfig=[0-9]" "openssl_found=yes" \
668 "openssl_version=[0-9]" "dev_headers=found" "hello=Hello, world!" || return 1
669 echo "success: bare (pkg-config + openssl, dev headers found via PKG_CONFIG_PATH), flakeref, and aliased deps all resolved"
670
671 # second run: same config + db, no upload. the devshell .drv is absent (no
672 # eval happens on a cache hit), so the env must be re-read from the cached
673 # env profile. the deps must still resolve exactly as on the build run.
674 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
675
676 check_needles "$out" \
677 "realizing cached NixOS config" \
678 "env_file=present" "pkgconfig=[0-9]" "openssl_found=yes" \
679 "openssl_version=[0-9]" "dev_headers=found" "hello=Hello, world!" || return 1
680 echo "success: cache-hit run re-read the devshell env from the cached profile (no drv) and all deps resolved"
681}
682
683test_activation_registry_pin() {
684 # the nixpkgs the image itself was built from; the registry override must NOT
685 # resolve to this. deterministic (locked in the repo flake), so safe to compare.
686 local base_nixpkgs
687 base_nixpkgs=$(nix eval --raw --impure --expr '(builtins.getFlake (toString ./.)).inputs.nixpkgs.outPath')
688
689 local config='{
690 '"$ACTIVATION_REGISTRY"'
691 }'
692 # the pinned nixpkgs must reach the system nix config: resolving it via the
693 # flakes CLI must not error "is not locked", and it must win the <nixpkgs> nixPath.
694 local out
695 out=$(run_vm --name "activation-registry-pin" --timeout "300s" --activate "$config" -- /run/current-system/sw/bin/bash -l -c '
696set -euo pipefail
697echo "lib_version=$(nix eval --raw nixpkgs#lib.version)"
698echo "nix_path=$(nix eval --raw --impure --expr "toString <nixpkgs>")"
699') || return 1
700
701 check_needles "$out" "lib_version=[0-9]" || return 1
702 local clean guest_nixpath
703 clean=$(echo "$out" | strip_ansi)
704 guest_nixpath=$(echo "$clean" | sed -n 's/^nix_path=//p' | head -n1)
705 if [ -z "$guest_nixpath" ] || [ "$guest_nixpath" = "$base_nixpkgs" ]; then
706 echo "error: guest <nixpkgs> nixPath did not resolve to the registry override (got '$guest_nixpath', base '$base_nixpkgs')" >&2
707 return 1
708 fi
709 echo "success: pinned nixpkgs registry reached the guest nix config (flakes CLI + <nixpkgs> nixPath)"
710}
711
712test_activation_cache_substitution() {
713 # a unique path that only exists in the configured (workflow) cache; the host
714 # seeds it so the guest can prove it substitutes through the read proxy.
715 local test_store_path
716 test_store_path=$(nix-build -E 'with import <nixpkgs> {}; writeText "activation-cache-test" "hello from the workflow cache"' --no-out-link)
717 nix copy --to "$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH" "$test_store_path"
718
719 # a trivial config: this test only cares that the read proxy serves the
720 # workflow-cache path during an activated run.
721 local out
722 out=$(run_vm --name "activation-cache-substitution" --timeout "300s" --activate '{}' -- /run/current-system/sw/bin/bash -l -c '
723set -euo pipefail
724store_path=$1
725# the unique path only exists in the configured cache, so realising it proves it
726# was substituted through the read proxy and not built or found elsewhere.
727nix-store --realise "$store_path" >/dev/null
728echo "substituted=$(cat "$store_path")"
729' bash "$test_store_path") || return 1
730
731 check_needles "$out" "substituted=hello from the workflow cache" || return 1
732 echo "success: unique path substituted from the workflow cache through the read proxy"
733}
734
735test_activation_docker() {
736 local config='{
737 "virtualisation": {
738 "docker": { "enable": true }
739 }
740 }'
741 # docker.service is up, but the daemon socket can lag a beat behind activation;
742 # wait for it to answer, then pull+run a real image. this drives the slimmed
743 # kernel modules: overlay.ko storage plus bridge/iptables networking out of the
744 # pruned tree, with outbound DNS/network over the guest slirp link.
745 local out
746 out=$(run_vm --name "activation-docker" --timeout "600s" --activate "$config" -- /run/current-system/sw/bin/bash -l -c '
747set -euo pipefail
748echo "docker_unit=$(systemctl is-active docker)"
749for i in $(seq 1 60); do docker info >/dev/null 2>&1 && break; sleep 1; done
750docker info >/dev/null
751echo "storage_driver=$(docker info --format "{{.Driver}}")"
752docker run --rm alpine cat /etc/alpine-release | sed "s/^/alpine_release=/"
753docker run --rm alpine echo container-ran-ok
754') || return 1
755
756 check_needles "$out" \
757 "^docker_unit=active$" "storage_driver=overlay(2|fs)" \
758 "alpine_release=[0-9]+\." "container-ran-ok" || return 1
759 echo "success: docker service active, pulled and ran an alpine container on the overlay storage driver"
760}
761
762test_activation_cached_realize() {
763 local config='{
764 "services": {
765 "openssh": {
766 "enable": true,
767 "authorizedKeysFiles": ["/etc/ssh/authorized_keys"]
768 }
769 }
770 }'
771 local db_path="$TEMP_DIR/activation-cached.db"
772
773 # first run: build the config, upload its closure, and record the toplevel in
774 # the db. nothing cached yet, so this builds from scratch.
775 local out
776 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
777 check_needles "$out" "^active$" || return 1
778
779 # second run: same config + db, no upload. must realize the recorded toplevel
780 # from the cache instead of rebuilding, and the cached system must come up.
781 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
782
783 check_needles "$out" "realizing cached NixOS config" "^active$" || return 1
784 echo "success: second run realized the cached NixOS config from the cache and sshd came up"
785}
786
787test_alpine() {
788 local hello_path
789 hello_path=$(nix-build -E 'with import <nixpkgs> {}; hello' --no-out-link)
790
791 local out
792 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine" --timeout "180s" --no-cache -- /bin/sh -lc '
793set -eu
794export HOME=/workspace
795hello_path=$1
796echo "release=$(cat /etc/alpine-release)"
797echo "user=$(id -un)"
798git version
799bash -c "echo bash=\$BASH_VERSION"
800touch /workspace/write-test
801echo "workspace writable"
802git ls-remote https://tangled.org/@tangled.org/core HEAD >/dev/null
803echo "git over https ok"
804apk add make
805echo "apk ok"
806# substitute a real package from cache.nixos.org over HTTPS and run it
807nix-store --realise "$hello_path" >/dev/null
808echo "ran=$("$hello_path/bin/hello")"
809' sh "$hello_path") || return 1
810
811 check_needles "$out" \
812 "release=" "user=spindle-workflow" "git version" "bash=" \
813 "workspace writable" "git over https ok" "apk ok" "ran=Hello, world!" || return 1
814 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"
815}
816
817test_alpine_podman() {
818 # install podman via apk and run a real container as the workflow user.
819 # rootless podman lives entirely in the writable workspace (storage + runroot
820 # under XDG dirs there), uses podman's default storage driver, and pulls over
821 # the guest network like the other alpine tests.
822 local out
823 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine-podman" --timeout "300s" --no-cache -- /bin/sh -lc '
824set -eu
825# no env setup here on purpose: shuttle seeds USER/LOGNAME/HOME/SHELL from the
826# workflow users passwd entry and provisions XDG_RUNTIME_DIR, so rootless podman
827# works out of the box. asserting those below doubles as a check on that.
828
829# shadow-uidmap ships newuidmap/newgidmap which rootless podman uses to apply the
830# /etc/subuid + /etc/subgid ranges baked into the image.
831apk add podman shadow-uidmap
832echo "user=$(id -un) USER=$USER HOME=$HOME XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
833echo "newuidmap=$(command -v newuidmap)"
834echo "podman_version=$(podman --version)"
835
836podman info >/dev/null
837echo "storage_driver=$(podman info --format "{{.Store.GraphDriverName}}")"
838
839podman run --rm --network=host docker.io/library/alpine cat /etc/alpine-release | sed "s/^/container_release=/"
840podman run --rm --network=host docker.io/library/alpine echo container-ran-ok
841' sh) || return 1
842
843 check_needles "$out" \
844 "user=spindle-workflow USER=spindle-workflow HOME=/workspace XDG_RUNTIME_DIR=/run/user/970" \
845 "newuidmap=/" "podman_version=" \
846 "storage_driver=" "container_release=[0-9]+\." "container-ran-ok" || return 1
847 echo "success: alpine guest installed podman via apk and pulled + ran a rootless container"
848}
849
850# asserts a store path's narinfo shows up in the local cache, retrying briefly
851# since the post-build-hook enqueues uploads asynchronously.
852cache_has_path() {
853 local path="$1"
854 local hash
855 hash=$(basename "$path" | cut -d'-' -f1)
856 local i
857 for i in $(seq 1 20); do
858 if curl -s -f "$CACHE_URL/${hash}.narinfo" > /dev/null; then
859 return 0
860 fi
861 sleep 0.5
862 done
863 return 1
864}
865
866test_alpine_nix() {
867 local test_store_path
868 test_store_path=$(nix-build -E 'with import <nixpkgs> {}; writeText "alpine-nix-test" "hello from cache to alpine"' --no-out-link)
869 nix copy --to "$CACHE_UPLOAD_URL?secret-key=$CACHE_SECRET_KEY_PATH" "$test_store_path"
870
871 # exercise the full local-cache path: daemon connectivity, substitution,
872 # store-db queries, and a build via *both* the classic (nix-build) and the
873 # new flakes/nix-command (nix build) frontends. the two build derivations
874 # use distinct names so we can confirm each got uploaded back to the cache.
875 local out
876 out=$(run_vm --spec "$ALPINE_IMAGE_SPEC_JSON" --name "alpine-nix" --timeout "180s" --upload -- /bin/sh -lc '
877set -eu
878export HOME=/workspace
879store_path=$1
880
881echo "nix_version=$(nix --version | head -n1)"
882{ nix store info >/dev/null 2>&1 || nix store ping >/dev/null 2>&1; } && echo "daemon=ok"
883
884# substitute a path from the cache and query the store db about it
885nix-store --realise "$store_path" >/dev/null
886echo "substituted=$(cat "$store_path")"
887echo "requisites=$(nix-store --query --requisites "$store_path" | wc -l | tr -d " ")"
888nix path-info --json "$store_path" >/dev/null && echo "path_info=ok"
889
890# build via the new CLI; the substituted path is declared as a real input
891# (builtins.storePath) so nix must realise it into the build sandbox first.
892# heredoc is unquoted (the outer guest script is single-quoted, so a quoted
893# delimiter would close it), hence \$ escapes what nix/the builder must expand.
894export DEP="$store_path"
895cat > /workspace/new.nix <<NIXEOF
896let dep = builtins.storePath (builtins.getEnv "DEP"); in
897derivation {
898 name = "alpine-nix-build-new";
899 system = "x86_64-linux";
900 builder = "/bin/sh";
901 # the sandbox only provides the sh builtin shell (no coreutils in PATH), so
902 # stick to builtins: the build only succeeds if nix realised the declared
903 # storePath dependency into the sandbox, where [ -r ] can see it.
904 args = [ "-c" "[ -r \${dep} ] && echo via-nix-build-with-dep > \$out" ];
905}
906NIXEOF
907new_path=$(nix build --impure --file /workspace/new.nix --no-link --print-out-paths)
908echo "new_path=$new_path"
909echo "new_content=$(tr "\n" "|" < "$new_path")"
910
911# build via the classic CLI
912cat > /workspace/old.nix <<NIXEOF
913derivation {
914 name = "alpine-nix-build-old";
915 system = "x86_64-linux";
916 builder = "/bin/sh";
917 args = [ "-c" "echo built-on-alpine > \$out" ];
918}
919NIXEOF
920old_path=$(nix-build /workspace/old.nix --no-out-link)
921echo "old_path=$old_path"
922' sh "$test_store_path") || return 1
923
924 check_needles "$out" \
925 "daemon=ok" "substituted=hello from cache to alpine" "path_info=ok" \
926 "requisites=[1-9][0-9]*" "new_content=via-nix-build-with-dep" || return 1
927
928 local clean new_path old_path
929 clean=$(echo "$out" | strip_ansi)
930 new_path=$(echo "$clean" | grep -o 'new_path=/nix/store/[a-z0-9]*-alpine-nix-build-new' | cut -d= -f2)
931 old_path=$(echo "$clean" | grep -o 'old_path=/nix/store/[a-z0-9]*-alpine-nix-build-old' | cut -d= -f2)
932 if [ -z "$new_path" ] || [ -z "$old_path" ]; then
933 echo "error: could not extract both built store paths from alpine guest output" >&2
934 return 1
935 fi
936 if ! cache_has_path "$new_path"; then
937 echo "error: nix-build (new CLI) output was not uploaded to the cache" >&2
938 return 1
939 fi
940 if ! cache_has_path "$old_path"; then
941 echo "error: nix-build (classic CLI) output was not uploaded to the cache" >&2
942 return 1
943 fi
944 echo "success: alpine guest substituted, queried the store db, built via both CLIs, and uploaded both outputs"
945}
946
947TESTS=(
948 test_alpine
949 test_alpine_nix
950 test_alpine_podman
951 test_realize
952 test_build_upload
953 test_ssh_store_upload
954 test_networking
955 test_substitution_and_no_upload
956 test_activation_services
957 test_activation_dependencies
958 test_activation_registry_pin
959 test_activation_cache_substitution
960 test_activation_docker
961 test_activation_cached_realize
962)
963
964log "running ${#TESTS[@]} tests"
965if ! run_tests; then
966 exit 1
967fi
968
969SUCCESS=1
970log "passed!!"