diff --git a/nix/checks.nix b/nix/checks.nix index bc04772b..6551916c 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -55,6 +55,9 @@ in inherit pkgs self nixosSystem; inherit (pkgs) lib; }; + # Runs ./host-modules/lib/refresh-consumer.nix's restart decision under a + # comma-decimal locale, against a stub `systemctl`. + script-test-refresh-consumer = import ./script-tests/refresh-consumer.nix { inherit pkgs; }; module-eval-nats-tls = import ./module-eval/nats-tls.nix { inherit pkgs self nixosSystem; inherit (pkgs) lib; diff --git a/nix/host-modules/lib/refresh-consumer.nix b/nix/host-modules/lib/refresh-consumer.nix index c27923df..5ba06fc0 100644 --- a/nix/host-modules/lib/refresh-consumer.nix +++ b/nix/host-modules/lib/refresh-consumer.nix @@ -46,9 +46,17 @@ # otherwise read as today's midnight. started="$(systemctl "$@" show --timestamp=us+utc -p ActiveEnterTimestamp --value "$unit")" if [ -n "$started" ]; then - started_us="$(date -u -d "$started" +%s%6N)" + started_us="$(date -u -d "$started" +%s%6N)" || started_us= + fi + written_us="$(date -u -r "$path" +%s%6N)" || written_us= + # Both are bare microsecond counts, with no decimal separator for a + # locale to change. One that is not is logged at `err` and nothing is + # restarted: the mtime stays, so the first run that can read both still + # restarts , where restarting on doubt restarts it on every run. + if [[ ! "$started_us" =~ ^[0-9]+$ || ! "$written_us" =~ ^[0-9]+$ ]]; then + echo "<3>cannot compare the mtime of $path ('$written_us') with the last start of $unit ('$started') — leaving $unit as it is" >&2 + return 0 fi - written_us="$(stat -c %.6Y "$path" | tr -d .)" if [ "$written_us" -le "$started_us" ]; then return 0 fi diff --git a/nix/script-tests/refresh-consumer.nix b/nix/script-tests/refresh-consumer.nix new file mode 100644 index 00000000..396efd55 --- /dev/null +++ b/nix/script-tests/refresh-consumer.nix @@ -0,0 +1,19 @@ +# `checks.script-test-refresh-consumer` — runs ../host-modules/lib/refresh-consumer.nix's +# `refresh_unit` and `refresh_consumer` against a stub `systemctl`, under a +# locale whose decimal separator is a comma. The cases are in +# ./refresh-consumer.sh. +# +# What runs is the rendered helper, sourced as its callers' scripts include it. +{ + pkgs, + ... +}: +pkgs.runCommand "hyperhive-script-test-refresh-consumer" + { + HELPER = pkgs.writeText "refresh-consumer.sh" (import ../host-modules/lib/refresh-consumer.nix { }); + LOCALE_ARCHIVE = "${pkgs.glibcLocales}/lib/locale/locale-archive"; + } + '' + ${pkgs.bash}/bin/bash ${./refresh-consumer.sh} + touch "$out" + '' diff --git a/nix/script-tests/refresh-consumer.sh b/nix/script-tests/refresh-consumer.sh new file mode 100644 index 00000000..68c665aa --- /dev/null +++ b/nix/script-tests/refresh-consumer.sh @@ -0,0 +1,130 @@ +# Cases for ./refresh-consumer.nix. Each case sets the credential file's mtime +# and the consumer's last start, runs the helper with a stub `systemctl` that +# records every call it is asked to act on, and compares that record and what +# the helper wrote to stderr. +set -uo pipefail + +failures=0 +count=0 + +fail() { + echo "FAILED: $case: $*" >&2 + failures=$((failures + 1)) +} + +dir=$(mktemp -d) +mkdir "$dir/bin" +calls=$dir/calls +path=$dir/token +: >"$path" + +# `show` prints $STARTED as systemd's `--timestamp=us+utc` does; `is-active` +# and `is-failed` answer from $ACTIVE and $FAILED; every other verb is +# appended to $calls. +cat >"$dir/bin/systemctl" <>"$calls" ;; +esac +STUB +chmod +x "$dir/bin/systemctl" +export PATH="$dir/bin:$PATH" ACTIVE=1 FAILED=0 STARTED= + +export LC_ALL=de_DE.UTF-8 + +# shellcheck source=/dev/null +. "$HELPER" + +# The locale the cases run under has to print the comma the helper must not +# depend on, or none of them tests it. +case="setup" +touch -d @1791232888.193159 "$path" +got=$(stat -c %.6Y "$path") +if [ "$got" != "1791232888,193159" ]; then fail "stat under $LC_ALL printed '$got', wanted 1791232888,193159"; fi + +# systemd's `--timestamp=us+utc` rendering of .. +systemd_ts() { + LC_ALL=C date -u -d "@$1" '+%a %Y-%m-%d %H:%M:%S.%6N UTC' +} + +# check +# +check() { + case=$1 + local want_calls=$2 want_err=$3 got err + shift 3 + count=$((count + 1)) + : >"$calls" + # `set -e` as the callers' NixOS `script`s run: a failed read must not end the + # caller. Not under `||` or `if`, which would switch `set -e` off. + local rc + ( + set -e + "$@" >/dev/null 2>"$dir/err" + ) + rc=$? + if [ "$rc" != 0 ]; then fail "exited $rc"; fi + err=$(cat "$dir/err") + got=$(cat "$calls") + if [ "$got" != "$want_calls" ]; then + fail "systemctl was asked:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want_calls" + fi + # A failed read also leaves `date`'s own message ahead of the helper's line. + if [ -n "$want_err" ]; then err=$(printf '%s\n' "$err" | tail -n 1); fi + if [ "$err" != "$want_err" ]; then + fail "stderr:"$'\n'"$err"$'\n'"wanted:"$'\n'"$want_err" + fi +} + +touch -d @1791232888.193159 "$path" + +STARTED=$(systemd_ts 1791232900.000001) +check "a file older than the start restarts nothing" "" "" \ + refresh_unit gitea-runner-swarm.service "$path" +check "nor through a container" "" "" \ + refresh_consumer swarm-ci-runner gitea-runner-swarm.service "$path" + +STARTED=$(systemd_ts 1791232888.193159) +check "a file written in the start's microsecond restarts nothing" "" "" \ + refresh_unit gitea-runner-swarm.service "$path" + +STARTED=$(systemd_ts 1791232888.193158) +check "a file one microsecond newer than the start restarts the unit" \ + "try-restart --no-block gitea-runner-swarm.service" "" \ + refresh_unit gitea-runner-swarm.service "$path" +check "through a container, with --machine" \ + "--machine=swarm-ci-runner try-restart --no-block gitea-runner-swarm.service" "" \ + refresh_consumer swarm-ci-runner gitea-runner-swarm.service "$path" + +FAILED=1 +check "a failed unit is reset and started" \ + "reset-failed gitea-runner-swarm.service"$'\n'"start --no-block gitea-runner-swarm.service" "" \ + refresh_unit gitea-runner-swarm.service "$path" +FAILED=0 + +STARTED= +check "a unit never active is restarted" \ + "try-restart --no-block gitea-runner-swarm.service" "" \ + refresh_unit gitea-runner-swarm.service "$path" + +ACTIVE=0 +check "nothing while the container is down" "" "" \ + refresh_consumer swarm-ci-runner gitea-runner-swarm.service "$path" +ACTIVE=1 + +STARTED="not a time" +check "an unreadable start restarts nothing and says so at err" "" \ + "<3>cannot compare the mtime of $path ('1791232888193159') with the last start of gitea-runner-swarm.service ('not a time') — leaving gitea-runner-swarm.service as it is" \ + refresh_unit gitea-runner-swarm.service "$path" + +STARTED=$(systemd_ts 1791232900.000001) +check "a missing file restarts nothing and says so at err" "" \ + "<3>cannot compare the mtime of $dir/missing ('') with the last start of gitea-runner-swarm.service ('$STARTED') — leaving gitea-runner-swarm.service as it is" \ + refresh_unit gitea-runner-swarm.service "$dir/missing" + +echo "$count cases, $failures failed" +[ "$failures" = 0 ]