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