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