# SPDX-License-Identifier: Apache-2.0 # Copyright Open Network Fabric Authors set unstable := true set shell := ["/usr/bin/env", "bash", "-euo", "pipefail", "-c"] set script-interpreter := ["/usr/bin/env", "bash", "-euo", "pipefail"] mod ci mod miri # enable to debug just recipes debug_justfile := "false" [private] _just_debuggable_ := if debug_justfile == "true" { "set -x" } else { "" } # number of nix derivations to build concurrently jobs := "8" # threads each nix derivation may use ("0" means every core on the machine). # # nix hands this to the builder as NIX_BUILD_CORES, which crane turns into # CARGO_BUILD_JOBS and nixpkgs' enableParallelBuilding turns into make -j, so # it is the cap on concurrent compile/link jobs *within* one derivation. The # cap on the whole build is therefore `jobs` x `cores`; a runner with fewer # cores than that will oversubscribe itself. cores := "0" # libc libc := if platform == "wasm32-wasip1" { "none" } else { "gnu" } # kernel (linux or wasip1) kernel := if platform == "wasm32-wasip1" { "wasip1" } else { "linux" } # List out the available commands [private] [default] @default: just --list --justfile {{ justfile() }} # cargo build profile (debug/release/checked) profile := "debug" export callgrind_package := "dataplane-routing" export callgrind_bench := "fib_lookup_callgrind" # sanitizers to use, comma-separated (address/cfi/leak/safe-stack/shadow-stack/thread/"") sanitize := "" # Native runs use each property's duration or iteration count. # A time cap overrides `.with_test_time` and can truncate `.with_iterations`. # Sanitized, emulated and feature-gated runs default to four seconds to bound runtime; # CI also requests this cap for deep runs. Explicit numeric values take precedence. # Empty or nonnumeric values select the defaults below. _bolero_test_time_ms := env("BOLERO_RANDOM_TEST_TIME_MS", "") export BOLERO_RANDOM_TEST_TIME_MS := if _bolero_test_time_ms =~ "^[0-9]+$" { _bolero_test_time_ms } else if sanitize != "" { "4000" } else if features != "" { "4000" } else if platform !~ "^x86-64" { "4000" } else { "" } bolero_coverage_test_time_ms := env("BOLERO_COVERAGE_TEST_TIME_MS", "15000") # comma-separated list of cargo features to enable (e.g. "shuttle") features := "" fuzz_max_input_length := env("FUZZ_MAX_INPUT_LENGTH", "65536") fuzz_corpus_root := env("FUZZ_CORPUS_ROOT", justfile_directory() / ".fuzz-corpus") fuzz_jobs := env("FUZZ_JOBS", `echo $(( ($(nproc) + 1) / 2 ))`) fuzz_len_control := env("FUZZ_LEN_CONTROL", "0") # whether to include default cargo features for this workspace (set to "false" to disable) default_features := "true" # Private computed cargo flag groups for consistent invocations. # Recipes should compose these as needed (not all cargo subcommands accept all flags). [private] _cargo_feature_flags := \ (if default_features == "false" { "--no-default-features " } else { "" }) \ + (if features != "" { "--features " + features } else { "" }) [private] _cargo_profile_flag := if profile == "debug" { "" } else { "--profile " + profile } # filters for nextest # # Under `shuttle`, the legacy `dataplane-quiescent` test layout had a # `shuttle` binary that hosted the bolero x shuttle suite, and we used # `--package=shuttle` (now an `-E 'package(shuttle)'`-style filter # embedded in nextest's argv) to isolate it. Today that suite is the # `quiescent_shuttle` module of `concurrency/tests/integration/`, so its # test names carry that prefix; matching the substring `shuttle` is good # enough. # # Under `loom`, the legacy filter `-E 'binary(loom)'` matched # `quiescent_loom`, the single integration-test binary that opted into # `loom::model`. After the concurrency rework, loom-compatible tests # are spread across several modules (`quiescent_model`, `thread_scope`, # `arc_weak`, `stress_dispatch`); the rest are gated with # `#![cfg(not(any(feature = "loom", ...)))]` and compile down to zero # tests under the loom feature. The `#[concurrency::test]`-routed tests # run under a `loom::model` body and are named for it, which is what the # filter matches. # Match all shuttle variants (`shuttle`, plus the additive # `shuttle_dfs` opt-in). # Under any shuttle backend, `concurrency::sync` types ARE shuttle # primitives, and touching them outside a `shuttle::check_*`-wrapped # body panics with `ExecutionState NotSet`. Tests that are designed # to run under shuttle either go through `#[concurrency::test]` (which # emits a `concurrency_model::` leaf -- the substring matches) # or live in a `*_shuttle` module / `*shuttle*` binary by convention. # Other workspace tests would fail spuriously without this filter. filter := if features =~ "^shuttle" { "shuttle" } else if features =~ "^loom" { "::concurrency_model::loom" } else { "" } # instrumentation to add, comma-separated (none/coverage/fuzz) instrument := "none" # target platform (x86-64-v3/bluefield2) platform := "x86-64-v3" version_extra := "" version_platform := if platform == "x86-64-v3" { "" } else { "-" + platform } version_profile := if profile == "release" { "" } else { "-" + profile } version_san := if sanitize == "" { "" } else { "-san." + replace(sanitize, ",", ".") } version_feat := if features == "" { "" } else { "-feat." + replace(features, ",", ".") } version := env("VERSION", `git describe --tags --dirty --always` + version_platform + version_profile + version_san + version_feat + version_extra) # Print version that will be used in the build version: @echo "Using version: {{version}}" # OCI repo to push images to oci_repo := "127.0.0.1:30000" oci_insecure := "" oci_name := "githedgehog/dataplane" oci_frr_prefix := "githedgehog/dataplane/frr" oci_image_dataplane := oci_repo + "/" + oci_name + ":" + version oci_image_dataplane_debugger := oci_repo + "/" + oci_name + "/debugger:" + version oci_image_dataplane_validator := oci_repo + "/" + oci_name + "/validator:" + version oci_image_frr_dataplane := oci_repo + "/" + oci_frr_prefix + ":" + version oci_image_frr_host := oci_repo + "/" + oci_frr_prefix + "-host:" + version [private] _skopeo_dest_insecure := if oci_insecure == "true" { "--dest-tls-verify=false" } else { "" } [private] nightly := "false" [private] docker_sock := "/var/run/docker.sock" # Directory the Docker daemon can see, when it cannot see this checkout's # /nix/store. Empty -- the ordinary case -- means the daemon runs on this host # and resolves store paths itself. # # n-vm's container tier hands the daemon bind-mount *sources*, and the daemon # resolves them in its own mount namespace. On a CI runner that is itself a # container talking to the host's daemon, /nix belongs to the runner image and # the bare metal has nothing at those paths. Because every mount asks Docker to # create a missing mount point, the daemon answers by creating an *empty* # directory rather than failing, so the guest root comes up empty and the first # mount beneath it dies on a read-only filesystem. # # Set this to a directory on a filesystem both sides share -- under this # workspace, which is the one path a containerised runner and its host agree on # -- and `setup-roots` will export the roots' closure into it. `n-vm` reads the # same variable and rewrites the mount sources; the targets stay `/nix/store`, # so rpaths in the guest are unaffected. n_vm_host_share := env("N_VM_HOST_SHARE_DIR", "") # Build a nix derivation with standard build arguments [script] build target="containers.dataplane" *args: {{ _just_debuggable_ }} mkdir -p results declare -r target="{{target}}" nix build -f default.nix "${target}" \ --argstr profile '{{ profile }}' \ --argstr sanitize '{{ sanitize }}' \ --argstr libc '{{ libc }}' \ --argstr kernel '{{ kernel }}' \ --argstr features '{{ features }}' \ --argstr default-features '{{ default_features }}' \ --argstr instrumentation '{{ instrument }}' \ --argstr platform '{{ platform }}' \ --argstr tag '{{version}}' \ --argstr nightly '{{nightly}}' \ --print-build-logs \ --show-trace \ --out-link "results/${target}" \ --max-jobs "{{jobs}}" \ --cores "{{cores}}" \ --keep-failed \ {{ args }} # run formatters for the code used in this project [script] fmt *args: {{ _just_debuggable_ }} cargo fmt {{args}} # run a series of pre-flight checks to catch most problems you might find in CI early [script] pre-flight: (fmt "--check") (test) (lint) (doctest) {{ _just_debuggable_ }} echo "pre flight checks pass" [script] test package="tests.all" *args: (setup-roots) (build (if package == "tests.all" { "tests.all" } else { "tests.pkg." + package }) args) {{ _just_debuggable_ }} declare -r target="{{ if package == "tests.all" { "tests.all" } else { "tests.pkg." + package } }}" # Export the scratch-container roots `setup-roots` just built, so that # `#[n_vm::test]` tests find them. The guard is for a tree where # `setup-roots` was skipped; the tests fail loudly rather than skip, so # this is a clearer error, not a fallback. if [[ -e testroot && -e vmroot ]]; then export N_VM_TEST_ROOT="$(pwd)/testroot" export N_VM_VM_ROOT="$(pwd)/vmroot" fi just n_vm_host_share="{{ n_vm_host_share }}" _export-test-extras # `trybuild` (n-vm-macros' compile-fail suite) shells out to # `cargo --offline` for a scratch project under `target/tests/trybuild`, # which resolves against the local registry cache rather than the nix # vendor directory the workspace was built from. CI builds through nix and # never populates that cache, so the suite fails there with "no matching # package named `proc-macro2`" while passing on any developer machine. # `--locked` means every download is checksum-checked against Cargo.lock, # and a warm cache makes this a no-op. cargo fetch --locked # `--no-tests pass`: a single-package archive whose only test(s) are # `#[cfg_attr(emulated, ignore)]` (e.g. n-vm-macros' trybuild test under # cross) runs zero tests; treat that as success, matching `test-each`. cargo nextest run --archive-file results/${target}/*.tar.zst --workspace-remap $(pwd) --no-tests pass {{ filter }} # List the bolero targets `just fuzz` can run. Args go to `cargo bolero list` [script] fuzz-list *args="": {{ _just_debuggable_ }} cargo bolero list --profile checked {{ _cargo_feature_flags }} {{ args }} # Fuzz one bolero target under libfuzzer. See development/code/running-tests.md [script] fuzz target time="60s" *args="": {{ _just_debuggable_ }} # libfuzzer wants a nightly compiler for its sanitizer coverage flags, while the # pinned toolchain is stable; --rustc-bootstrap bridges that. cargo-bolero already # builds with the fuzz profile and links AddressSanitizer unless told otherwise, so # a plain `just fuzz` is already an asan run. Findings land under # `{{ fuzz_corpus_root }}` (`.fuzz-corpus` unless `FUZZ_CORPUS_ROOT` says otherwise), # not in a `__fuzz__` directory beside the test -- that moved when the coverage corpora # were isolated from the unit tests. # # `sanitize=thread` additionally rebuilds std: thread instrumentation changes the # ABI, so a std left uninstrumented fails the build on a mismatch against `core`. # asan does not need that, and skipping the std rebuild keeps it far quicker. # `sanitize=NONE` drops instrumentation altogether, which buys roughly four times # the executions per second in exchange for only catching what the test asserts. case "{{ sanitize }}" in "") want=address ;; NONE) want=none ;; *) want="{{ sanitize }}" ;; esac sysroot="${DATAPLANE_SYSROOT:-}" if [ -n "${sysroot}" ] && [ -r "${sysroot}/.sanitize" ]; then built_with="$(cat "${sysroot}/.sanitize")" built_with="${built_with:-none}" if [ "${want}" != "${built_with}" ] && [ -z "{{ sanitize }}" ]; then printf 'warning: rust is built with %s and this sysroot with %s, so the C dependencies -- dpdk above all -- are not instrumented.\n' \ "${want}" "${built_with}" >&2 printf ' `just sanitize=NONE fuzz ...` instruments neither and runs about four times quicker.\n' >&2 elif [ "${want}" != "${built_with}" ]; then printf 'refusing to fuzz: sanitize=%s was asked for, but this sysroot was built with sanitize=%s.\n' \ "{{ sanitize }}" "${built_with:-}" >&2 printf 'rebuild the C dependencies and re-enter the shell with:\n' >&2 printf ' just sanitize=%s setup-roots && nix-shell --argstr sanitize %s\n' \ "{{ sanitize }}" "{{ sanitize }}" >&2 exit 1 fi elif [ -n "{{ sanitize }}" ] && [ "{{ sanitize }}" != "NONE" ]; then # A sanitizer run requires a readable stamp to verify the sysroot's instrumentation. printf 'refusing to fuzz: cannot verify sanitize=%s without a readable\n' \ "{{ sanitize }}" >&2 printf '.sanitize stamp (DATAPLANE_SYSROOT=%s).\n' \ "${sysroot:-}" >&2 printf 'rebuild the sysroot and re-enter the shell with:\n' >&2 printf ' just sanitize=%s setup-roots && nix-shell --argstr sanitize %s\n' \ "{{ sanitize }}" "{{ sanitize }}" >&2 exit 1 fi inherited="$(cargo config get -Zunstable-options --format json-value build.rustflags 2>/dev/null | jq -r 'join(" ")')" export RUSTFLAGS="${inherited} ${RUSTFLAGS:-}" sancov_rt="$(clang -print-file-name=libclang_rt.fuzzer_no_main-$(uname -m).a 2>/dev/null || true)" if [ -f "${sancov_rt}" ]; then export RUSTFLAGS="${RUSTFLAGS} -Clink-arg=${sancov_rt} -Clink-arg=-lstdc++" else printf 'warning: no libFuzzer runtime beside clang; packages with a non-bolero test binary will not link.\n' >&2 fi corpus_dir="{{ fuzz_corpus_root }}/$(printf '%s' '{{ target }}' | tr -c 'A-Za-z0-9_.-' '_')" mkdir -p "${corpus_dir}" cargo bolero test '{{ target }}' --rustc-bootstrap -T '{{ time }}' \ --profile checked \ --corpus-dir "${corpus_dir}" \ -l '{{ fuzz_max_input_length }}' \ -E='-len_control={{ fuzz_len_control }}' \ -j '{{ fuzz_jobs }}' \ {{ if sanitize != "" { "--sanitizer " + sanitize } else { "" } }} \ {{ if sanitize == "thread" { "--build-std" } else { "" } }} \ {{ _cargo_feature_flags }} {{ args }} [private] [script] _bench-release-only: {{ _just_debuggable_ }} if [ '{{ profile }}' != "release" ]; then echo "error: benchmarks want profile=release, not '{{ profile }}'" >&2 echo " run: just profile=release bench" >&2 exit 1 fi [doc("Wall-clock time, via criterion")] [script] bench *args: _bench-release-only (build "benches") {{ _just_debuggable_ }} shopt -s nullglob for bench in ./results/benches/bin/*; do case "${bench}" in *_callgrind) continue ;; esac "${bench}" --bench {{ args }} done if [ -f target/criterion/report/index.html ]; then echo echo "html report: target/criterion/report/index.html" fi [doc("Instructions and cache traffic, via iai-callgrind")] [script] bench-callgrind *args: {{ _just_debuggable_ }} cargo bench -p "${callgrind_package}" --bench "${callgrind_bench}" {{ args }} [doc("Compare against a baseline and print a markdown report")] [script] bench-compare baseline="base" *args: {{ _just_debuggable_ }} mkdir -p results/bench # Scope baseline detection to this suite, allowing an optional target triple. # Keep benchmark failures out of the save-baseline path. scope="*/${callgrind_package}/${callgrind_bench}/*" if find target/iai -type f -path "${scope}" -name '*base@{{ baseline }}*' -print -quit 2>/dev/null | grep -q .; then cargo bench -p "${callgrind_package}" --bench "${callgrind_bench}" -- \ --baseline='{{ baseline }}' --output-format=json > results/bench/run.jsonl else echo "no baseline '{{ baseline }}' for ${callgrind_package}/${callgrind_bench}; recording one" >&2 cargo bench -p "${callgrind_package}" --bench "${callgrind_bench}" -- \ --save-baseline='{{ baseline }}' --output-format=json > results/bench/run.jsonl fi ./scripts/bench-report.ts results/bench/run.jsonl {{ args }} [doc("Record a baseline for `bench-compare`, without reporting")] [script] bench-baseline name="base": {{ _just_debuggable_ }} cargo bench -p "${callgrind_package}" --bench "${callgrind_bench}" -- \ --save-baseline='{{ name }}' > /dev/null echo "recorded baseline '{{ name }}'" [doc("Serve the criterion html report over http")] [script] bench-serve port="8080": {{ _just_debuggable_ }} just serve ./target/criterion '{{ port }}' report/index.html [script] build-each *args: (build "workspace" args) {{ _just_debuggable_ }} [script] check package="" *args: (build (if package == "" { "check" } else { "check." + package }) args) {{ _just_debuggable_ }} [script] check-each *args: (build "check" args) {{ _just_debuggable_ }} # Check every package on its own, all targets, without building or running anything; see # `check-each` in `default.nix`. [script] check-each-target *args: (build "check-each" args) {{ _just_debuggable_ }} [script] test-each *args: (setup-roots) (build "tests.pkg" args) {{ _just_debuggable_ }} # Same two requirements as `test`: the per-package archives include # `dataplane-n-vm`'s guest-booting suite, and n-vm-macros' trybuild suite # resolves against the local registry cache. if [[ -e testroot && -e vmroot ]]; then export N_VM_TEST_ROOT="$(pwd)/testroot" export N_VM_VM_ROOT="$(pwd)/vmroot" fi just n_vm_host_share="{{ n_vm_host_share }}" _export-test-extras cargo fetch --locked declare -a fail=() for test_archive in results/tests.pkg*/*.tar.zst; do if ! cargo nextest run --archive-file "${test_archive}" --workspace-remap "$(pwd)" --no-tests pass; then fail+=("${test_archive} failed") fi done if [ "${#fail[@]}" -gt 0 ]; then >&2 printf '%s\n' "${fail[@]}" exit 1 fi [script] docs package="" *args: (build (if package == "" { "docs.all" } else { "docs.pkg." + package }) args) {{ _just_debuggable_ }} # Build the rustdoc HTML (all crates, or one by directory name) and open it in a browser [script] docs-open package="" *args: (docs package args) {{ _just_debuggable_ }} index="results/{{ if package == "" { "docs.all" } else { "docs.pkg." + package } }}/index.html" echo "docs: file://$(realpath "${index}")" xdg-open "${index}" # Like docs-open, but runs cargo doc in the dev shell rather than nix, so edits rebuild incrementally. # Args go to cargo doc (e.g. --document-private-items) [script] docs-quick package="" *args: {{ _just_debuggable_ }} # Appended rather than set: the dev shell's RUSTDOCFLAGS already carries the KaTeX/Mermaid header. export RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -Zunstable-options --enable-index-page" export RUSTC_BOOTSTRAP=1 cargo doc --no-deps {{ _cargo_profile_flag }} {{ _cargo_feature_flags }} \ {{ if package == "" { "--workspace" } else { "--manifest-path " + package / "Cargo.toml" } }} {{ args }} index="target/${CARGO_BUILD_TARGET:+${CARGO_BUILD_TARGET}/}doc/index.html" echo "docs: file://$(realpath "${index}")" xdg-open "${index}" # Remove test containers n-vm left behind. Args go to n-vm-reap (--force, --list, --all) [script] reap *args: {{ _just_debuggable_ }} # The host tier cleans up after itself on every route it can reach, # including SIGTERM and SIGINT, so this is only needed after a SIGKILL, an # OOM kill, or a reboot -- none of which give a process the chance to tidy # up. Containers whose creating process is still alive are left alone # unless `--all` is passed, so this is safe to run while other tests are # going. Without a tty it refuses to remove anything; CI should pass # `--force`. cargo run --quiet -p dataplane-n-vm --features reap --bin n-vm-reap -- {{ args }} # Create devroot, sysroot, testroot, and vmroot symlinks for local development [script] setup-roots *args: {{ _just_debuggable_ }} # The toolchain roots take the feature list. The guest roots must not. # # `devroot`/`sysroot`: `features` picks the panic runtime. `loom` and `shuttle` # require `panic = "unwind"` and `-Zbuild-std` has to match (see `mk-needs-unwind` # in default.nix), so withholding it here would hand a shuttle build a # `panic_abort` std. # # `testroot`/`vmroot`: qemu, cloud-hypervisor, virtiofsd, a kernel, an initramfs # and the in-VM runner. None of that is the code under test -- the test binaries # arrive at runtime under `test-bin`. Passing the feature list only asks crates # that never declared it to build with it, and `just shuttle` consequently died # in `setup-roots` with "the package 'dataplane-n-preinit' does not contain this # feature: shuttle", before a single test ran. It is the same trade the pre-init # already makes for the sanitizer: it is the scaffolding that execs the code # under test, not the code under test. # # `tag` reaches a build as `VERSION`, and the guest roots are the one place # nothing reads it: the only `option_env!("VERSION")` call sites are in # `dataplane`, `mgmt`, `config` and `args`, and the guest payload is # `n-preinit`, `n-it`, the kernel and the initramfs -- whose only internal # dependency is `n-vm-protocol`. But `version` is # `git describe --tags --dirty --always`, so it moves on every commit *and* # on any uncommitted edit anywhere in the tree, and it is an input to both # root derivations. Left alone, that rebuilds n-preinit -> initramfs -> # kernel-image -> testroot -> n-it -> vmroot for a change of any kind, # including one that touches nothing these roots contain. # # So pin it for the two guest roots, the same way the feature list is # already withheld from them. `devroot` and `sysroot` keep the real tag: # the test binaries built against them do report a version. for root in devroot sysroot testroot vmroot; do declare -a featureargs tagargs case "${root}" in testroot|vmroot) featureargs=() tagargs=( --argstr tag guest ) ;; *) featureargs=( --argstr default-features '{{ default_features }}' --argstr features '{{ features }}' ) tagargs=( --argstr tag '{{version}}' ) ;; esac nix build -f default.nix "${root}" \ "${featureargs[@]}" \ "${tagargs[@]}" \ --argstr instrumentation '{{ instrument }}' \ --argstr kernel '{{ kernel }}' \ --argstr libc '{{ libc }}' \ --argstr nightly '{{nightly}}' \ --argstr platform '{{ platform }}' \ --argstr profile '{{ profile }}' \ --argstr sanitize '{{ sanitize }}' \ --out-link "${root}" \ {{ args }} done if [ -n "{{ n_vm_host_share }}" ]; then just n_vm_host_share="{{ n_vm_host_share }}" export-scratch-roots fi # Export what test archives link beyond the scratch roots (init's gateway image), which is built # with the archives and so after `setup-roots` has exported the rest. A no-op without the share. [private] [script] _export-test-extras: {{ _just_debuggable_ }} if [ -z "{{ n_vm_host_share }}" ]; then exit 0 fi shopt -s nullglob declare -a extras=( results/tests*/gateway-rootfs ) if (( ${#extras[@]} > 0 )); then just n_vm_host_share="{{ n_vm_host_share }}" export-scratch-roots "${extras[@]}" fi # Copy the scratch roots' nix closure somewhere the Docker daemon can read it. # See `n_vm_host_share`; a no-op unless that is set. [script] export-scratch-roots *extra: {{ _just_debuggable_ }} declare -r share="{{ n_vm_host_share }}" if [ -z "${share}" ]; then echo "n_vm_host_share is unset; nothing to export" exit 0 fi declare -r store="${share}/nix/store" # `tmp` is the other half: the forwarded-environment directory is a bind # source too, and a container-local /tmp is no more visible than /nix. mkdir -p "${store}" "${share}/tmp" # The closure, not the whole store. `/nix/store` is mounted into the # container whole, so the export has to cover everything the container # resolves through it, which is three things: # # testroot/vmroot the host tier's qemu, cloud-hypervisor and virtiofsd, # the guest kernel, and n-it inside the guest root # sysroot what the *test binary* is linked against. Missing this # is not a missing-file error: the binary is there and # execs, and the kernel reports ENOENT for its absent # ELF interpreter, so the container exits 127 with # "No such file or directory" naming a path that plainly # exists. # qemu-user what *runs* the test binary when it is foreign-arch. # Not a root and not reachable from one; see below. # # devroot is deliberately not here. It is the toolchain -- 4.6 GiB against # sysroot's 2.0 -- and nothing inside the container compiles. # `extra`: what a test archive needs beyond these, built after them (see `_export-test-extras`). declare -a roots=( testroot vmroot sysroot {{ extra }} ) declare -a resolved=() for root in "${roots[@]}"; do if [ ! -e "${root}" ]; then >&2 echo "::error::${root} is missing; run setup-roots first" exit 1 fi resolved+=( "$(readlink -f "${root}")" ) done # The third of those, and the one that is in none of the roots: qemu-user is # a dev-shell package (`default.nix`), # so it reaches a job through PATH and nothing else. A cross-arch guest # cannot exec the foreign test binary directly, so n-vm runs it under # `qemu-`, resolved with `find_on_path` and handed to the container as # an absolute /nix/store path (`n-vm/src/container.rs`). Unexported, the # container starts, every mount lands, and tini dies on the command itself: # # [FATAL tini (7)] exec /nix/store/...-qemu-user-11.1.0/bin/qemu-aarch64 # failed: No such file or directory # # Export the whole package rather than the one binary this job happens to # need, so this does not have to know which arch is being cross-tested, and # export it unconditionally: 19 of its 20 requisites are already in the # roots' closure, so the marginal cost is one path and 124 MiB against # 2.7 GiB, and a condition for when it is needed is the same mechanism that # left `cross` without the share to begin with. Any `qemu-` name will # do as a handle on the package. declare qemu_binary if qemu_binary="$(command -v qemu-aarch64 2>/dev/null)" \ || qemu_binary="$(command -v qemu-x86_64 2>/dev/null)"; then declare qemu_package qemu_package="$(readlink -f "${qemu_binary}")" resolved+=( "${qemu_package%/bin/*}" ) else >&2 echo "::warning::qemu-user is not on PATH; a cross job's containers will exit 127" fi declare -a paths mapfile -t paths < <(nix-store --query --requisites "${resolved[@]}") declare -i copied=0 kept=0 for path in "${paths[@]}"; do declare dest="${store}/$(basename "${path}")" # A store path is immutable, so an entry that is already here is already # right. This is what makes the second job on a runner cheap. if [ -e "${dest}" ]; then kept+=1 continue fi # Copy to a private name and rename, so a concurrent job never observes # a half-copied path under a name that promises a whole one. declare staging="${store}/.staging-$$-$(basename "${path}")" rm -rf -- "${staging}" cp -a --no-preserve=ownership -- "${path}" "${staging}" # A store path's directories are r-xr-xr-x, which nix relies on to keep # them immutable. A copy has no such contract, and inheriting the mode # makes the export undeletable: `rm -rf` cannot unlink a child of a # directory it cannot write. Whoever cleans this runner should not have # to know that. chmod -R u+w -- "${staging}" if ! mv -T -- "${staging}" "${dest}" 2>/dev/null; then # Lost the race; the winner's copy is as good as ours. chmod -R u+w -- "${staging}" 2>/dev/null || true rm -rf -- "${staging}" fi copied+=1 done # Reported from nix rather than `du`: on a copy-on-write filesystem `du` # run straight after the copy reports blocks that are still dirty, which # made a 1.8 GiB export read as 26 MiB. printf 'exported %d store paths to %s (%d already present, %s closure)\n' \ "${copied}" "${store}" "${kept}" \ "$(nix path-info -S "${resolved[@]}" 2>/dev/null | awk '{t+=$2} END {printf "%.1f GiB", t/1024/1024/1024}')" [private] [script] _refuse-instrumented-artifact: if [ -n '{{ instrument }}' ] && [ '{{ instrument }}' != "none" ]; then printf 'refusing to build a container at instrument=%s: an instrumented build is a diagnostic,\n' '{{ instrument }}' >&2 printf 'not an artifact, and instrumentation is not part of the version -- so this image would\n' >&2 printf 'take a clean image tag and replace it.\n' >&2 exit 1 fi # Build the dataplane container image [script] build-container target="dataplane" *args: _refuse-instrumented-artifact (build (if target == "validator" { "workspace.validator" } else { "containers." + target }) args) {{ _just_debuggable_ }} declare -xr DOCKER_HOST="${DOCKER_HOST:-unix://{{docker_sock}}}" case "{{target}}" in "dataplane") docker load < ./results/containers.dataplane docker tag "ghcr.io/githedgehog/dataplane:{{version}}" "{{oci_image_dataplane}}" echo "imported {{ oci_image_dataplane }}" ;; "dataplane-debugger") docker load < ./results/containers.dataplane-debugger docker tag "ghcr.io/githedgehog/dataplane/debugger:{{version}}" "{{oci_image_dataplane_debugger}}" echo "imported {{ oci_image_dataplane_debugger }}" ;; "debug-tools") # Uses nix only to produce a base image with the runtime closure (glibc, bash, etc.) # then layers locally-compiled cargo binaries on top via Dockerfile. # See the `build-container-quick` recipe. docker load < ./results/containers.debug-tools echo "imported debug-tools:dev" ;; "frr.dataplane") docker load < ./results/containers.frr.dataplane docker tag "ghcr.io/githedgehog/dataplane/frr:{{version}}" "{{oci_image_frr_dataplane}}" echo "imported {{oci_image_frr_dataplane}}" ;; "frr.host") docker load < ./results/containers.frr.host docker tag "ghcr.io/githedgehog/dataplane/frr-host:{{version}}" "{{oci_image_frr_host}}" echo "imported {{oci_image_frr_host}}" ;; "validator") echo "NOTE: validator image is wasm and not containerized" ;; *) >&2 echo "{{target}} is not a valid container" exit 99 esac # WARNING: The resulting image must NEVER be pushed to a shared registry. # NOTE: this recipe intentionally does not depend on build-container "debug-tools" to make the call fast. # Quick (non-sterile) container build using local cargo artifacts [script] build-container-quick: {{ _just_debuggable_ }} docker build \ --file ./Dockerfile \ --build-arg PROFILE="{{profile}}" \ --label sterile="false" \ --annotation sterile="false" \ --tag "dataplane:dev" \ . echo "imported dataplane:dev" # Build and push the dataplane container [script] push-container target="dataplane" *args: (build-container target args) && version {{ _just_debuggable_ }} declare -xr DOCKER_HOST="${DOCKER_HOST:-unix://{{docker_sock}}}" # Preserve completed builds across transient registry failures. Skopeo # retries blobs; this outer loop retries known-safe, idempotent whole pushes # and announces them so registry degradation remains visible. retry() { declare -r what="$1" shift declare -ri attempts=4 declare -i attempt=1 declare -i delay declare log log="$(mktemp)" declare -r log while true; do # Stream multi-gigabyte pushes so they do not appear hung. if "$@" 2>&1 | tee "${log}"; then rm -f -- "${log}" return 0 fi if [ "${attempt}" -ge "${attempts}" ]; then >&2 echo "::error::${what} failed after ${attempts} attempts" rm -f -- "${log}" return 1 fi # Match registry status and transport vocabulary shared by skopeo # and oras. Bound 403 so it cannot match inside a digest. if ! grep -qiE \ -e 'blob upload (unknown|invalid)|blob transfer' \ -e '\b(403|429|500|502|503|504)\b' \ -e 'forbidden|denied|too many requests|rate limit' \ -e 'internal server error|bad gateway|service unavailable|gateway time-?out' \ -e 'temporarily unavailable|try again' \ -e 'unexpected EOF|connection reset|broken pipe|i/o timeout|TLS handshake' \ "${log}"; then >&2 echo "::error::${what} failed with a non-retryable error" rm -f -- "${log}" return 1 fi delay=$(( 5 * 2 ** (attempt - 1) + RANDOM % 5 )) >&2 echo "::warning::${what} failed (attempt ${attempt}/${attempts}), retrying in ${delay}s" sleep "${delay}" attempt=$(( attempt + 1 )) done } push_image() { declare -r image="$1" retry "push of ${image}" \ skopeo copy --retry-times=3 --src-daemon-host="${DOCKER_HOST}" \ {{ _skopeo_dest_insecure }} "docker-daemon:${image}" "docker://${image}" echo "Pushed ${image}" } case "{{target}}" in "dataplane") push_image "{{ oci_image_dataplane }}" ;; "dataplane-debugger") push_image "{{ oci_image_dataplane_debugger }}" ;; "debug-tools") >&2 echo "do not push the debug tools!" exit 1 ;; "frr.dataplane") push_image "{{oci_image_frr_dataplane}}" ;; "frr.host") push_image "{{oci_image_frr_host}}" ;; "validator") if [ "{{platform}}" != "wasm32-wasip1" ]; then >&2 echo "Pushing non wasm32-wasip1 validator images is not supported, set platform=wasm32-wasip1" exit 1 fi pushd ./results/workspace.validator/bin retry "push of {{ oci_image_dataplane_validator }}" \ oras push --annotation version="{{ version }}" "{{ oci_image_dataplane_validator }}" ./validator.wasm popd echo "Pushed {{ oci_image_dataplane_validator }}" ;; *) >&2 echo "{{target}} is not a valid container" exit 99 esac # Note: deliberately ignores all recipe parameters save version, debug_justfile, # oci_repo, and the jobs/cores build-parallelism caps. # Pushes all release container images. [script] push: {{ _just_debuggable_ }} for container in dataplane frr.dataplane validator; do if [ "${container}" = "validator" ]; then platform="wasm32-wasip1" else platform="x86-64-v3" fi just jobs="{{jobs}}" cores="{{cores}}" debug_justfile="{{debug_justfile}}" oci_repo="{{oci_repo}}" version="{{version}}" profile=release platform="${platform}" sanitize= instrument=none push-container "${container}" done # Print names of container images to build or push [script] print-container-tags: echo "{{ oci_image_dataplane }}" # Check dependency licenses, bans, sources and security advisories [arg("advisories", long="no-advisories", value="false")] [script] check-dependencies advisories="true": {{ _just_debuggable_ }} # cargo-deny has no option to skip a check, list the others instead. cargo deny {{ _cargo_feature_flags }} check {{ if advisories == "false" { "licenses bans sources" } else { "" } }} # Check security advisories, failing only on those not already reported for "base". # # Unlike the other cargo-deny checks, advisories change over time: a new RUSTSEC entry for a # crate already used on main would otherwise fail every PR and merge queue run. Advisories # already present on the base are reported as warnings, and passed as output "result" in GitHub # Actions. With "--all", fail on all advisories instead, without comparing with the base. [arg("all", long="all", value="true")] [script] check-advisories base="origin/main" all="false": {{ _just_debuggable_ }} declare tmp tmp="$(mktemp -d)" declare -r tmp trap 'rm -rf "${tmp}"; git worktree prune' EXIT # Compare with the fork point, so that changes made on the base since then are not ours. declare fork="" if [ "{{ all }}" != "true" ]; then fork="$(git merge-base "{{ base }}" HEAD)" fi declare -r fork # Fetch the database once, then run offline, so that both sides see the same advisories. cargo deny fetch db # Print one " " line per error; fail if cargo-deny itself failed. # The version is left out, so that bumping a crate to a version that is still affected # does not count as a new failure. deny_errors() { local -r dir="$1" out="$2" local -i rc=0 (cd "${dir}" && cargo fetch --locked >/dev/null) (cd "${dir}" && cargo deny --offline --format json {{ _cargo_feature_flags }} check advisories) \ 2>"${out}.json" || rc=$? jq -rR 'fromjson? | select(.type == "diagnostic" and .fields.severity == "error") | .fields | "\(.advisory.id // .code) \(.graphs[]?.Krate.name // "-")"' "${out}.json" \ | sort -u >"${out}" if [ "${rc}" -ne 0 ] && [ ! -s "${out}" ]; then >&2 jq -rR 'fromjson? // {fields: {message: .}} | .fields.message' "${out}.json" return 1 fi } if ! deny_errors . "${tmp}/head"; then >&2 echo "::error::cargo-deny failed to check advisories" exit 1 fi if [ -z "${fork}" ]; then : >"${tmp}/base" elif [ ! -s "${tmp}/head" ] \ || git diff --quiet "${fork}" -- Cargo.lock deny.toml ':(glob)**/Cargo.toml'; then # No change to the dependency graph or to the configuration: nothing can be new. cp "${tmp}/head" "${tmp}/base" else git worktree add --quiet --detach "${tmp}/base-tree" "${fork}" if ! deny_errors "${tmp}/base-tree" "${tmp}/base"; then # Fail closed: count everything as new. >&2 echo "::warning::cargo-deny failed on {{ base }}, cannot tell which advisories are new" : >"${tmp}/base" fi fi comm -12 "${tmp}/head" "${tmp}/base" >"${tmp}/known" comm -23 "${tmp}/head" "${tmp}/base" >"${tmp}/new" declare id crate while read -r id crate; do echo "::warning::${id} (${crate}) is already reported on {{ base }}" done <"${tmp}/known" if [ -n "${GITHUB_OUTPUT:-}" ]; then { echo "result<>"${GITHUB_OUTPUT}" fi while read -r id crate; do if [ -z "${fork}" ]; then >&2 echo "::error::${id} (${crate})" else >&2 echo "::error::${id} (${crate}) is not reported on {{ base }}" fi done <"${tmp}/new" if [ -s "${tmp}/new" ]; then # Show the full diagnostics, in human-readable form. cargo deny --offline {{ _cargo_feature_flags }} check advisories || true exit 1 fi # Ensure the shared dependency derivations stay reusable across revisions. # # Two things break that, and they are different kinds of thing, so they take # different questions. # # The workspace source is a store path, so "the dependency build must not # depend on it" is a statement about the derivation graph and nix can answer it # outright: instantiate once and read the inputs. That is exact, it names the # offending path, and it needs no edit to the working tree. # # The git version is a string. It reaches a derivation as an environment # variable and never as an input path, so no graph walk can see it; the only # way to ask is to instantiate under two tags and compare. # # Both have regressed before, and both surface as a slow cache miss rather than # a failure, which is why they are checked at all. [script] check-deps-reuse: {{ _just_debuggable_ }} # Keep Nix stderr; it is the only diagnostic when instantiation fails. declare src src="$(nix eval --raw --impure --expr '(import ./default.nix { }).src.outPath')" declare -r src if [ -z "${src}" ]; then >&2 echo "::error::could not resolve the workspace source path" exit 1 fi deps_drv() { declare drv drv="$(nix-instantiate default.nix -A "$1" --argstr tag "$3" | tail -1)" grep -ao "/nix/store/[a-z0-9]\{32\}-$2[^\"]*\.drv" "${drv}" | sort -u } # Report through the status; command substitution would run this in a # subshell and discard failure-count updates. check_reuse() { declare -r attr="$1" name="$2" declare baseline if ! baseline="$(deps_drv "${attr}" "${name}" dev)" || [ -z "${baseline}" ]; then >&2 echo "::error::could not resolve ${name} from ${attr}" return 1 fi # The source question, put to the graph. declare drv while IFS= read -r drv; do [ -z "${drv}" ] && continue if nix-store -q --requisites "${drv}" | grep -qxF "${src}"; then >&2 echo "::error::${name} depends on the workspace source" >&2 echo " ${src}" >&2 echo " is a build input of ${drv}" return 1 fi done <<<"${baseline}" # The version question, put to two instantiations. declare tagged if ! tagged="$(deps_drv "${attr}" "${name}" v0.25.2-15-gdeadbee-dirty)"; then >&2 echo "::error::could not resolve ${name} from ${attr} with a release tag" return 1 fi if [ "${tagged}" != "${baseline}" ]; then >&2 echo "::error::${name} depends on the git version" >&2 echo " tag=dev -> ${baseline}" >&2 echo " tag=v0.. -> ${tagged}" return 1 fi printf '%s is reusable: %s\n' "${name}" "${baseline}" } # Check production and test flag sets independently: a regression in a # production-only flag would sail past a test-only guard. declare -r -A targets=( [workspace.dataplane]="dataplane-deps" [tests.all]="dataplane-tests-deps" ) declare -i failures=0 for attr in "${!targets[@]}"; do check_reuse "${attr}" "${targets[${attr}]}" || failures=$(( failures + 1 )) done if [ "${failures}" -ne 0 ]; then exit 1 fi [script] opengrep: {{ _just_debuggable_ }} opengrep scan --experimental --verbose --error --config .semgrep/rules [script] pinact *args="--check --verify": {{ _just_debuggable_ }} pinact run {{ args }} [script] zizmor *args="": {{ _just_debuggable_ }} zizmor --persona=pedantic {{args}} . # Run the CI-equivalent cached lint; direct Cargo remains the fast inner loop. clippy package="" *args: (build (if package == "" { "clippy.all" } else { "clippy.pkg." + package }) args) {{ _just_debuggable_ }} [script] actionlint: {{ _just_debuggable_ }} actionlint # Keep default.nix formatted without adopting legacy files under nix/. [script] nixfmt *args="--check": {{ _just_debuggable_ }} nixfmt {{ args }} default.nix # Keep the lint recipe, workflow steps, and outcome aggregation aligned; drift # in any of the three silently disables a check. [script] check-lint-wiring: {{ _just_debuggable_ }} declare -r wf=".github/workflows/dev.yml" declare -i failures=0 # Nix-backed checks run under a `ci::check-` prefix; accept either spelling # so a recipe stays covered when it moves between workflow jobs. declare recipe while read -r recipe; do grep -qE "recipe: \"(ci::check-)?${recipe}\"" "${wf}" && continue >&2 echo "::error::\`just lint\` runs ${recipe}, but ${wf} never does" failures=$(( failures + 1 )) done < <(just --dump --dump-format json | jq -r '.recipes.lint.dependencies[].recipe') # Every lint step is `continue-on-error`, so a step the aggregator does not # read cannot fail the run. declare id while read -r id; do grep -qF "steps.${id}.outcome" "${wf}" && continue >&2 echo "::error::${wf} runs ${id} but never reads its outcome" failures=$(( failures + 1 )) done < <(yq -r '.jobs.lint.steps[] | select(.id) | .id' "${wf}") if [ "${failures}" -ne 0 ]; then exit 1 fi echo "lint wiring agrees" # Images are per-revision, so verify that each realized image and its # dockerTools assembly paths are rejected by the actual Cachix push filter. [script] check-push-filter: {{ _just_debuggable_ }} declare -r action=".github/actions/nix-shell/action.yml" declare push_filter push_filter="$(yq -r '.runs.steps[] | select(.with.pushFilter) | .with.pushFilter' "${action}")" declare -r push_filter if [ -z "${push_filter}" ] || [ "${push_filter}" = "null" ]; then >&2 echo "::error::no pushFilter found in ${action}; nothing is keeping images out of the cache" exit 1 fi # `containers` nests, so recurse rather than assume one level. Each line is "attr outPath drvPath". declare -a images=() mapfile -t images < <( nix eval --impure --raw --expr ' let d = import ./default.nix { }; lib = d.pkgs.lib; flatten = prefix: set: lib.concatLists (lib.mapAttrsToList (n: v: let nm = if prefix == "" then n else "${prefix}.${n}"; in if lib.isDerivation v then [ "${nm} ${v.outPath} ${v.drvPath}" ] else if builtins.isAttrs v then flatten nm v else [ ] ) set); in lib.concatStringsSep "\n" (flatten "" { inherit (d) containers; }) + "\n" ' ) if [ "${#images[@]}" -eq 0 ]; then >&2 echo "::error::found no container images to check; did the attribute move?" exit 1 fi declare -i failures=0 declare -i checked=0 for entry in "${images[@]}"; do [ -z "${entry}" ] && continue declare name out drv base read -r name out drv <<<"${entry}" # Recover the base name after `source-volatile` has renamed the output. base="${out##*/}" base="${base#*-dataplane-volatile-}" base="${base%.tar.gz}" declare -a candidates=( "${out}" ) # Test dockerTools artifacts; ordinary closure dependencies stay cached. mapfile -t -O "${#candidates[@]}" candidates < <( nix-store -q --requisites "${drv}" 2>/dev/null \ | grep '\.drv$' \ | xargs -r nix-store -q --outputs 2>/dev/null \ | sort -u \ | while IFS= read -r path; do declare stem="${path##*/}" case "${stem#*-}" in "${base}-base.json" | "${base}-conf.json" \ | "${base}-customisation-layer" | "${base}-env" \ | "stream-${base}") printf '%s\n' "${path}" ;; esac done ) for path in "${candidates[@]}"; do checked=$(( checked + 1 )) if ! printf '%s\n' "${path}" | grep -qE "${push_filter}"; then >&2 echo "::error::${name} would push ${path##*/} to Cachix" failures=$(( failures + 1 )) fi done done if [ "${failures}" -ne 0 ]; then >&2 echo "::error::extend the pushFilter in ${action}, or mark the image with \`source-volatile\`" exit 1 fi printf 'no cache leak: %d paths across %d artifacts\n' "${checked}" "${#images[@]}" # Limit linting to tracked Markdown so generated files cannot affect CI. [script] markdownlint *args: {{ _just_debuggable_ }} git ls-files -z '*.md' | xargs -0 markdownlint-cli2 {{ args }} [script] license-headers: {{ _just_debuggable_ }} declare -i res=0 for f in $(git ls-files '*.rs' '*.sh' justfile); do if ! head "${f}" | grep -wq 'SPDX'; then echo "::error::Missing SPDX license header in file ${f}" res=1 fi if ! head "${f}" | grep -wqi 'copyright'; then echo "::error::Missing copyright notice in file ${f}" res=1 fi done exit ${res} # NOTE: commitlint-rs's `--from`/`--to` flags are unusable in any # non-interactive shell (CI, this recipe, etc): its arg-handling checks stdin # before checking --from/--to, and stdin is never a TTY there, so it silently # lints empty/stray stdin content instead of the requested commit range. # See https://github.com/KeisukeYamashita/commitlint-rs/blob/main/cli/src/args.rs # Work around it by feeding each commit's message to commitlint individually # over stdin, which is the one invocation mode that actually works. [script] commitlint base="origin/main": {{ _just_debuggable_ }} declare -i status=0 while IFS= read -r sha; do if ! git log -1 --format=%B "${sha}" | commitlint; then echo "::error::commit ${sha} failed commitlint" >&2 status=1 fi done < <(git log --format=%H --no-merges "{{base}}"..HEAD) exit "${status}" # Run linters [script] lint: \ (fmt "--check") \ (clippy) \ (commitlint) \ (check-dependencies "false") \ (check-advisories) \ (opengrep) \ (zizmor) \ (pinact "--fix=false" "--no-api") \ (actionlint) \ (markdownlint) \ (nixfmt) \ (check-lint-wiring) \ (check-push-filter) \ (license-headers) \ (duvet-check) {{ _just_debuggable_ }} # Cargo cannot archive doctests, so run them inside the Nix sandbox. doctest package="" *args: (build (if package == "" { "doctests.all" } else { "doctests.pkg." + package }) args) {{ _just_debuggable_ }} # Run instrumented tests and report coverage. Args are forwarded to nextest; for example, # `just coverage -p dataplane-nat` scopes the run to this crate. [script] coverage *args: (setup-roots) {{ _just_debuggable_ }} export BOLERO_RANDOM_TEST_TIME_MS="{{ bolero_coverage_test_time_ms }}" # Export the scratch-container roots `setup-roots` just built, so that # `#[n_vm::test]` tests find them. The guard is for a tree where # `setup-roots` was skipped; the tests fail loudly rather than skip, so # this is a clearer error, not a fallback. if [[ -e testroot && -e vmroot ]]; then export N_VM_TEST_ROOT="$(pwd)/testroot" export N_VM_VM_ROOT="$(pwd)/vmroot" fi # init's end-to-end test runs the gateway image. Nix builds compile its path in; this build is # cargo's own, so it is handed over here. just build gateway-rootfs export DATAPLANE_TEST_GATEWAY_ROOTFS="$(readlink -f results/gateway-rootfs)" # See the `test` recipe: trybuild resolves its scratch project against the # local registry cache. cargo fetch --locked export LLVM_COV="$(pwd)/devroot/bin/llvm-cov" export LLVM_PROFDATA="$(pwd)/devroot/bin/llvm-profdata" declare -r out="./target/nextest/coverage" cargo llvm-cov clean --workspace cargo llvm-cov --no-report --branch nextest {{ args }} mkdir -p "${out}" cargo llvm-cov report --branch --html --output-dir="${out}" cargo llvm-cov report --branch --lcov --output-path="${out}/lcov.info" cargo llvm-cov report --branch --codecov --output-path="${out}/codecov.json" cargo llvm-cov report --branch --summary-only echo echo "html report: ${out}/html/index.html (\`just serve-coverage\` to browse it)" serve_host := "127.0.0.1" [doc("Serve a directory of generated html over http")] [script] serve dir port="8080" index="index.html": {{ _just_debuggable_ }} if [ ! -d '{{ dir }}' ]; then echo "error: no such directory: {{ dir }}" >&2 exit 1 fi server="$(command -v static-web-server || true)" if [ -z "${server}" ] && [ -x ./devroot/bin/static-web-server ]; then server="$(pwd)/devroot/bin/static-web-server" fi if [ -z "${server}" ]; then echo "error: static-web-server not found; re-enter the dev shell, or \`just setup-roots\`" >&2 exit 1 fi echo "serving {{ dir }} at http://{{ serve_host }}:{{ port }}/{{ index }} (ctrl-c to stop)" "${server}" --root '{{ dir }}' --host '{{ serve_host }}' --port '{{ port }}' --log-level warn serve-coverage port="8080": (serve "./target/nextest/coverage/html" port) [script] duvet *args: {{ _just_debuggable_ }} duvet report {{ args }} [script] duvet-check: {{ _just_debuggable_ }} for input in .duvet/config.toml .duvet/snapshot.txt; do if [ ! -f "${input}" ]; then echo "error: ${input} is missing; this check has nothing to compare and cannot pass" >&2 exit 1 fi done committed="$(mktemp -d)" trap 'rm -rf "${committed}"' EXIT cp -a .duvet/requirements "${committed}/requirements" cp -a .duvet/snapshot.txt "${committed}/snapshot.txt" trap 'rm -rf .duvet/requirements .duvet/snapshot.txt; \ mv "${committed}/requirements" .duvet/requirements; \ mv "${committed}/snapshot.txt" .duvet/snapshot.txt; \ rmdir "${committed}"' EXIT duvet report stale=0 diff -u "${committed}/snapshot.txt" .duvet/snapshot.txt || stale=1 diff -ru "${committed}/requirements" .duvet/requirements || stale=1 if [ "${stale}" != 0 ]; then echo "error: the duvet report is stale; run \`just duvet\` and commit the result" >&2 exit 1 fi [script] mutants *args: {{ _just_debuggable_ }} cargo mutants --test-tool nextest {{ args }} [script] spec-interlock *args: {{ _just_debuggable_ }} ./scripts/spec-interlock.ts {{ args }} [script] duvet-summary *args: {{ _just_debuggable_ }} ./scripts/duvet-summary.ts {{ args }} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" # Use Nix-built archives so local and CI coverage report the same binaries. [script] coverage-archive package="tests.all" *args: (setup-roots) {{ _just_debuggable_ }} declare -r target="{{ if package == "tests.all" { "tests.all" } else { "tests.pkg." + package } }}" just \ jobs="{{jobs}}" \ cores="{{cores}}" \ debug_justfile="{{debug_justfile}}" \ profile="{{profile}}" \ libc="{{libc}}" \ sanitize="{{sanitize}}" \ features="{{features}}" \ default_features="{{default_features}}" \ platform="{{platform}}" \ nightly="{{nightly}}" \ instrument=coverage \ build "${target}" declare -r root="$(pwd)" declare -r out="${root}/target/coverage" declare -r profraw="${out}/profraw" declare -r extract="${out}/extract" rm -rf -- "${out}" mkdir -p -- "${profraw}" "${extract}" # Make the count below see zero instead of a literal unmatched glob. shopt -s nullglob declare -ra archives=( "results/${target}"/*.tar.zst ) shopt -u nullglob if [ "${#archives[@]}" -ne 1 ]; then >&2 echo "::error::expected exactly one archive in results/${target}, found ${#archives[@]}" exit 1 fi declare -r archive="${archives[0]}" declare -r prefix_file="results/${target}/source-prefix" if [ ! -r "${prefix_file}" ]; then >&2 echo "::error::${prefix_file} is missing; the archive predates it, rebuild it" exit 1 fi declare src_prefix src_prefix="$(cat "${prefix_file}")" declare -r src_prefix # llvm-cov resolves relative remaps against the vanished build sandbox. # Reject absolute prefixes and redirect relative ones to this worktree. case "${src_prefix}" in /*) >&2 echo "::error::source prefix ${src_prefix} is absolute; coverage expects a relative remap" exit 1 ;; esac # llvm-cov emits the resolved absolute paths and Codecov wants them relative # to the repository root, so the root goes into a BRE below. Escape it. declare root_re root_re="$(sed -e 's#[].[^$*\\/]#\\&#g' <<<"${root}")" declare -r root_re # Nextest changes cwd; `%m` also pools compatible profiles across tests. export LLVM_PROFILE_FILE="${profraw}/cov-%m.profraw" # This recipe -- not `coverage` -- is what CI runs (`ci::coverage`), and it # runs the tests on the runner rather than in the nix sandbox, so the # guest-booting tests need the same two things `test` gives them. if [[ -e testroot && -e vmroot ]]; then export N_VM_TEST_ROOT="${root}/testroot" export N_VM_VM_ROOT="${root}/vmroot" fi just n_vm_host_share="{{ n_vm_host_share }}" _export-test-extras # See the `test` recipe: trybuild resolves its scratch project against the # local registry cache, which a nix-only CI job never populates. cargo fetch --locked # Report partial coverage before propagating a test failure. declare -i test_status=0 cargo nextest run \ --archive-file "${archive}" \ --extract-to "${extract}" \ --workspace-remap "${root}" \ {{ filter }} {{ args }} || test_status="$?" declare -r profraw_list="${out}/profraw.list" find "${profraw}" -type f -name '*.profraw' > "${profraw_list}" if [ ! -s "${profraw_list}" ]; then >&2 echo "::error::no raw profiles were written; was ${archive} built with instrument=coverage?" exit 1 fi llvm-profdata merge -sparse --input-files="${profraw_list}" -o "${out}/coverage.profdata" # Pass one primary object and filter reports to workspace sources. declare target_dir target_dir="$(jq -er '."rust-build-meta"."target-directory"' "${extract}/target/nextest/binaries-metadata.json")" declare -r target_dir declare -a objects=() while IFS= read -r binary; do if [ ! -x "${binary}" ]; then >&2 echo "::error::${binary} is listed in the archive metadata but is not present" exit 1 fi if [ "${#objects[@]}" -eq 0 ]; then objects+=( "${binary}" ) else objects+=( -object "${binary}" ) fi done < <( jq -er --arg prefix "${target_dir}/" --arg extract "${extract}/target/" \ '."rust-binaries"[]."binary-path" | $extract + ltrimstr($prefix)' \ "${extract}/target/nextest/binaries-metadata.json" ) # Resolve remapped paths against this worktree and filter out the standard # library and native dependencies. llvm-cov ignores nonexistent filters, # so the trailing path must name the real tree. declare -ra scope=( --compilation-dir="${root}" "${objects[@]}" "${root}" ) llvm-cov export \ --format=lcov \ --instr-profile="${out}/coverage.profdata" \ "${scope[@]}" \ | sed -e "s#^SF:${root_re}/#SF:#" > "${out}/lcov.info" # Codecov needs repository-relative paths; reject failed rewrites. if grep -q '^SF:/' "${out}/lcov.info"; then >&2 echo "::error::absolute paths survived the ${root} rewrite:" >&2 grep -m5 '^SF:/' "${out}/lcov.info" exit 1 fi # A filter that matches nothing reports full coverage of an empty set, which # reads as success everywhere downstream. Insist on some workspace source. if ! grep -q '^SF:' "${out}/lcov.info"; then >&2 echo "::error::no workspace sources in the report; the ${root} filter matched nothing" exit 1 fi llvm-cov show \ --format=html \ --output-dir="${out}/html" \ --show-branches=count \ --instr-profile="${out}/coverage.profdata" \ "${scope[@]}" llvm-cov report \ --instr-profile="${out}/coverage.profdata" \ "${scope[@]}" echo "lcov report: ${out}/lcov.info" echo "html report: ${out}/html/index.html" exit "${test_status}" # Regenerate the dependency graph for the project [script] depgraph: {{ _just_debuggable_ }} cargo depgraph --exclude dataplane-test-utils,dataplane-dpdk-sysroot-helper --workspace-only \ | sed 's/dataplane-//g' \ | dot -Grankdir=TD -Gsplines=polyline -Granksep=1.5 -Tsvg > workspace-deps.svg [script] bump-actions: {{ _just_debuggable_ }} pinact run --update export GITHUB_STEP_SUMMARY := env("GITHUB_STEP_SUMMARY", "") export GITHUB_OUTPUT := env("GITHUB_OUTPUT", "") [script] bump-cargo-deps: {{ _just_debuggable_ }} declare BASE BASE="$(git rev-parse HEAD)" declare -r BASE # Run "cargo update" echo "::notice::Running cargo update" cargo update if ! git diff --quiet; then echo "Found changes after cargo update, creating commit" git add Cargo.lock git commit -sm "bump!: regular dependency update" fi # Check updates available with "cargo upgrade", # then bump each package individually through separate commits echo "::notice::Looking for dependencies to upgrade" declare upgrade_output upgrade_output="$(mktemp)" declare -r upgrade_output declare list_packages list_packages="$(mktemp)" declare -r list_packages cargo upgrade --incompatible=allow --dry-run | tee "${upgrade_output}" sed "/^====/d; /^name .*old req .*new req/d; s/ .*//" "${upgrade_output}" > "${list_packages}" nb_upgrades=$(wc -l < "${list_packages}") echo "Found the following ${nb_upgrades} upgrade(s) available:" cat "${list_packages}" echo "::notice::Upgrading packages that need an upgrade (if any), one by one" declare commit_msg commit_msg="$(mktemp)" declare -r commit_msg while read -r package; do echo "bump(cargo)!: bump $package (cargo upgrade)" | tee "${commit_msg}" tee -a "${commit_msg}" <<<"" cargo upgrade --incompatible=allow --package "$package" | tee -a "${commit_msg}" git add Cargo.lock Cargo.toml cli/Cargo.toml git commit -sF "${commit_msg}" done < "${list_packages}" # If we did not create any commits, we do not need to create a PR message if [[ "$(git rev-parse HEAD)" = "${BASE}" ]]; then rm -f -- "${upgrade_output}" "${list_packages}" "${commit_msg}" exit 0 fi echo "::notice::We created the following commits:" git log --reverse -p "${BASE}".. # Create Pull Request description declare upgrade_log upgrade_log="$(mktemp)" declare -r upgrade_log if [[ "${nb_upgrades}" -ge 1 ]]; then { echo "### :rocket: Upgrades available"; echo "" echo "| name | old | req | compatible | latest |"; echo "|------|-----|-----|------------|--------|"; awk '{print "| " $1 " | " $2 " | " $3 " | " $4 " | " $5 " |"}' < <(sed 1,2d < "${upgrade_output}"); echo "" echo ":warning: This Pull Request was automatically generated and should be carefully reviewed before acceptance. It may introduce **breaking changes**." echo "" } > "${upgrade_log}" fi if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -n "${GITHUB_OUTPUT:-}" ] && [ -w "${GITHUB_STEP_SUMMARY}" ] && [ -w "${GITHUB_OUTPUT}" ]; then cat "${upgrade_log}" > "${GITHUB_STEP_SUMMARY}" { echo "upgrade<> "${GITHUB_OUTPUT}" fi rm -f -- "${upgrade_log}" "${upgrade_output}" "${list_packages}" "${commit_msg}" # Bump the minor version in Cargo.toml and reset patch version to 0 [script] bump_minor_version: CURRENT_VERSION="$(tomlq --raw-output '.workspace.package.version' Cargo.toml)" echo "Current version: ${CURRENT_VERSION}" MAJOR_VNUM="$(cut -d. -f1 <<<"${CURRENT_VERSION}")" MINOR_VNUM="$(cut -d. -f2 <<<"${CURRENT_VERSION}")" NEW_VERSION="${MAJOR_VNUM}.$((MINOR_VNUM + 1)).0" just bump_version "${NEW_VERSION}" # Bump the version in Cargo.toml to the specified version (for example, "1.2.3") [script] bump_version version: declare -r new_version="{{ version }}" echo "New version: ${new_version}" sed -i "s/^version = \".*\"/version = \"${new_version}\"/" Cargo.toml cargo update --workspace # Enter nix-shell [script] shell: nix-shell \ --argstr default-features '{{ default_features }}' \ --argstr features '{{ features }}' \ --argstr instrumentation '{{ instrument }}' \ --argstr kernel '{{ kernel }}' \ --argstr libc '{{ libc }}' \ --argstr nightly '{{nightly}}' \ --argstr platform '{{ platform }}' \ --argstr profile '{{ profile }}' \ --argstr sanitize '{{ sanitize }}' \ --argstr tag '{{version}}'