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