diff --git a/COVERAGE.md b/COVERAGE.md index 714ce819..e78d02e4 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -148,8 +148,10 @@ Pull request #2 merged on 2026-09-26 (merge commit 5a0a381) and brought | `conf/turnkey.d/postfix-local` | the build-time postfix configuration | 100 percent, 17 of 17 lines, 7 bats | | `conf/turnkey.d/dpkg-vendor` | points the dpkg vendor at Keel, and removes an inherited TurnKey origin | 100 percent, 7 of 7 lines, 16 bats | | `conf/turnkey.d/apt-identity` | keeps the shipped apt User-Agent the one in force | 100 percent, 4 of 4 lines, 11 bats | +| `lib/version-files.sh` | the grammar and the prefix rules of the two identity files (decision 0014) | 100 percent, 17 of 17 lines | +| `bin/keel-version-files` | the thin main `mk/turnkey.mk` and `mk/turnkey-desktop.mk` call in `root.patched/post` to write `/etc/turnkey_version` and `/etc/keel_version` | 100 percent, 36 of 36 lines | -34 bats, measured on 2026-09-29 with kcov 43 and bats 1.11. The gate in +34 bats, measured on 2026-09-29 with kcov 43 and bats 1.11, plus the 37 bats of the identity files (`tests/version-files.bats`, and `tests/mk-identity.bats`, which make runs against stubs of fab), measured on 2026-09-29. The gate in `.github/workflows/tests.yml` is set to 100, the measured number, and is only ever raised. The sections that follow record the state before the first merge. @@ -193,6 +195,37 @@ they were last. All three are `run !` now, so none of them depends on its position, and the check runs for every repository in the reusable `test-shell` workflow. +## The two identity files + +`mk/turnkey.mk` and `mk/turnkey-desktop.mk` write both in `root.patched/post`, after every overlay, +conf script, patch and removelist of the build, so nothing can clobber +them. The version string comes from the first line of the product +changelog through fab's `turnkey-version.py`, and its prefix is normalised +before either file is written: + +- `/etc/turnkey_version` always begins `turnkey-`, because it is an + interface: `sysversion`, the `turnkey-version` command, inithooks' + `29tagid` and `keel inspect` all parse it by prefix, and `keel inspect` + drops the appliance identity outright for a string that begins with + anything else. +- `/etc/keel_version` is the same four fields with the `keel-` prefix, and + is what the appliance reads when it says what it is. + +Without the normalisation a repository that renames its release package +(`keel-core-19.0`, as keel-core did on 2026-09-27) silently produces an +`/etc/turnkey_version` that none of those parsers accepts. + +The make recipe itself is run, not read: `tests/mk-identity.bats` makes the +`root.patched/post` step of both `mk/turnkey.mk` and `mk/turnkey-desktop.mk` +against stubs of fab under `tests/mk/` and reads the two files back, 8 +tests. It also holds the two ways the call can fail: a `common` checkout +that predates `bin/keel-version-files` stops the build with a message that +names the path and says the checkout is stale, rather than a bare `No such +file or directory` after the whole root was built; and the version string is +quoted, so an empty one or one with a space is refused as a version rather +than as a wrong argument count. make has no line coverage, so +`tests/coverage.sh` runs this suite for its verdict alone. + ## Baseline before the merge: 0 percent, nothing measured This repository has no test suite and no coverage tool wired up, so nothing diff --git a/bin/keel-version-files b/bin/keel-version-files new file mode 100755 index 00000000..9132b1f6 --- /dev/null +++ b/bin/keel-version-files @@ -0,0 +1,91 @@ +#!/bin/bash +# Writes the two identity files of an image being built: the compatibility +# file /etc/turnkey_version and the Keel file /etc/keel_version (decision +# 0014). Called once per product by mk/turnkey.mk, in root.patched/post, +# with the version string fab's turnkey-version.py derived from the first +# line of the product changelog and the root of the tree being built. +# +# This is the thin main of lib/version-files.sh (decision 0004): it parses +# the arguments, refuses what it cannot name and writes the two files; the +# grammar and the prefix rules are in the library and are unit tested. +# +# exit 0 both files written +# exit 1 bad usage, or a version string that is not an appliance identity +# exit 2 the tree cannot be written to +# exit 3 the library is missing +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +lib=${KVF_LIB:-$here/../lib/version-files.sh} + +usage() { + cat <&2 + echo "keel-version-files: two arguments are required, got $#" >&2 + exit 1 +fi + +if [ ! -r "$lib" ]; then + echo "keel-version-files: $lib is missing; it is lib/version-files.sh of common" >&2 + exit 3 +fi +# shellcheck source=../lib/version-files.sh +. "$lib" + +release=$1 +root=$2 + +app_version=$(kvf_app_version "$release") +if ! kvf_is_app_version "$app_version"; then + echo "keel-version-files: '$release' is not an appliance identity; the" \ + "first line of the changelog must name a release whose version" \ + "string is app-version-codename-architecture, as in" \ + "turnkey-core-19.0 or keel-core-19.0" >&2 + exit 1 +fi + +if [ ! -d "$root" ]; then + echo "keel-version-files: '$root' is not a directory" >&2 + exit 2 +fi + +etc=$root/etc +mkdir -p "$etc" 2>/dev/null || true +if [ ! -d "$etc" ] || [ ! -w "$etc" ]; then + echo "keel-version-files: cannot write into '$etc'" >&2 + exit 2 +fi + +write_identity() { + # write_identity PREFIX FILE + local string + string=$(kvf_version_string "$1" "$app_version") + printf '%s\n' "$string" > "$etc/$2" + chmod 0644 "$etc/$2" + echo "$etc/$2: $string" +} + +write_identity "$KVF_TURNKEY_PREFIX" turnkey_version +write_identity "$KVF_KEEL_PREFIX" keel_version diff --git a/changes/turnkey.changelog b/changes/turnkey.changelog index b2e365ea..9a456491 100644 --- a/changes/turnkey.changelog +++ b/changes/turnkey.changelog @@ -1,5 +1,20 @@ turnkey-core-19.0 (1) turnkey; urgency=low + * An image says what it is: /etc/keel_version, written beside + /etc/turnkey_version in root.patched/post by bin/keel-version-files + (decision 0014). Both carry the same four fields, app, version, + codename and architecture, from the first line of the product + changelog through fab's turnkey-version.py; the Keel file begins + keel-, the compatibility file always begins turnkey-, whatever the + changelog's release package is called, because sysversion, + turnkey-version, 29tagid and keel inspect parse it by that prefix. + Keel's turnkey-version fork reads /etc/keel_version first. Desktop + builds (mk/turnkey-desktop.mk) write both too, and a common checkout + without the script stops the build naming it, instead of dying with + "No such file" after the whole root was built. tests/version-files.bats + and tests/mk-identity.bats, which makes the recipe of both makefiles + against stubs of fab, with the real seal-root last. + * An image is exported with root locked, or the build fails. The last step of root.patched, mk/turnkey/seal-root, accepts a root password field of '*', '!' or '!*' and nothing else, without printing it, then diff --git a/lib/version-files.sh b/lib/version-files.sh new file mode 100644 index 00000000..252102e5 --- /dev/null +++ b/lib/version-files.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Pure helpers of bin/keel-version-files (decision 0004: logic apart from +# effect). Nothing here writes a file, runs a command, reads the clock or +# looks at anything but its arguments. Sourced by bin/keel-version-files +# and by tests/version-files.bats. +# +# What the two identity files are, and why there are two (decision 0014): +# +# /etc/turnkey_version the compatibility contract. Its name and its +# grammar, turnkey----, +# are an interface: the sysversion library, the +# turnkey-version command, inithooks' 29tagid and +# keel's own inspect all parse it, and every one of +# those parsers is prefix sensitive. It therefore +# always begins with "turnkey-", whatever the +# product's changelog calls the release package. +# /etc/keel_version what this appliance says it is. Same grammar, +# "keel-" prefix, and the file everything Keel +# presents to an operator reads first. +# +# The input is the string fab's turnkey-version.py builds from the first +# line of the product changelog, which is why the prefix cannot be trusted: +# a repository that renames its release package (keel-core-19.0, as +# keel-core did on 2026-09-27) would otherwise write an /etc/turnkey_version +# that none of those parsers accepts. + +# Which prefixes name a product rather than an appliance. +# shellcheck disable=SC2034 # KVF_TURNKEY_PREFIX and _KEEL_PREFIX are read by the caller +KVF_PREFIXES="turnkey keel" +KVF_TURNKEY_PREFIX=turnkey +KVF_KEEL_PREFIX=keel + +# kvf_has_prefix TEXT +# TEXT begins with one of the product prefixes and its hyphen. The one rule +# both helpers below apply: kvf_app_version removes such a prefix, and +# kvf_is_app_version refuses a string that still carries one. +kvf_has_prefix() { + local text=${1-} prefix + for prefix in $KVF_PREFIXES; do + if [ "${text#"$prefix"-}" != "$text" ]; then + return 0 + fi + done + return 1 +} + +# kvf_app_version RELEASE_NAME +# The --- part of a release version string, +# with at most one product prefix removed: turnkey-wordpress-19.0-trixie-amd64 +# and keel-core-19.0-trixie-amd64 both lose their first field, a name with +# neither prefix is returned whole, and an app whose own name starts with +# the other product's name keeps it. +kvf_app_version() { + local text=${1-} + if kvf_has_prefix "$text"; then + text=${text#*-} + fi + printf '%s\n' "$text" +} + +# kvf_is_app_version TEXT +# TEXT carries the four fields an appliance identity needs: +# ---, app lower case and possibly +# hyphenated, version starting with a digit so a release tag such as +# 19.0rc is part of it, codename and architecture one field each. A string +# that still carries a product prefix fails, because "turnkey" would then +# be read as the app: the prefix comes off first, with kvf_app_version. +kvf_is_app_version() { + local text=${1-} + if kvf_has_prefix "$text"; then + return 1 + fi + [[ $text =~ ^[a-z0-9][a-z0-9.+-]*-[0-9][^-]*-[a-z][a-z0-9]*-[a-z0-9]+$ ]] +} + +# kvf_version_string PREFIX APP_VERSION +# One identity string: the product prefix and the four fields. +kvf_version_string() { + printf '%s-%s\n' "${1-}" "${2-}" +} diff --git a/mk/turnkey-desktop.mk b/mk/turnkey-desktop.mk index 2de6b7a9..b61b9ece 100644 --- a/mk/turnkey-desktop.mk +++ b/mk/turnkey-desktop.mk @@ -30,6 +30,9 @@ COMMON_REMOVELISTS += turnkey COMMON_REMOVELISTS_FINAL += turnkey FAB_SHARE_PATH ?= /usr/share/fab +# This repository, as the build sees it. bin/keel-version-files writes the +# two identity files of the image (decision 0014). +COMMON_BIN_PATH ?= $(FAB_PATH)/common/bin # below hacks allow inheritors to define their own hooks, which will be # prepended. warning: first line *needs* to be empty for this to work @@ -47,7 +50,8 @@ endef bootstrap/post += $(_bootstrap/post) # tag package management system with release package -# set /etc/turnkey_version +# set /etc/turnkey_version and /etc/keel_version (bin/keel-version-files, +# decision 0014) # # The apt User-Agent is no longer written here, for the reason given in # mk/turnkey.mk: overlays/turnkey.d/apt-identity ships it (Keel-Linux/common#6). @@ -55,7 +59,7 @@ define _root.patched/post # # tagging package management system with release package - # setting /etc/turnkey_version + # setting /etc/turnkey_version and /etc/keel_version # @if [ -f $(FAB_PATH)/products/core/changelog ]; then \ echo $(FAB_SHARE_PATH)/make-release-deb.py $(FAB_PATH)/products/core/changelog $O/root.patched; \ @@ -64,8 +68,9 @@ define _root.patched/post @if [ -f ./changelog ]; then \ echo $(FAB_SHARE_PATH)/make-release-deb.py ./changelog $O/root.patched; \ $(FAB_SHARE_PATH)/make-release-deb.py ./changelog $O/root.patched; \ - turnkey_version=$$($(FAB_SHARE_PATH)/turnkey-version.py --dist=$(CODENAME) --tag=$(VERSION_TAG) ./changelog $(FAB_ARCH)); \ - echo $$turnkey_version > $O/root.patched/etc/turnkey_version; \ + release_version=$$($(FAB_SHARE_PATH)/turnkey-version.py --dist=$(CODENAME) --tag=$(VERSION_TAG) ./changelog $(FAB_ARCH)); \ + [ -x $(COMMON_BIN_PATH)/keel-version-files ] || { echo "ERROR: $(COMMON_BIN_PATH)/keel-version-files is missing or not executable: the common checkout predates the identity files of decision 0014, update it" >&2; exit 1; }; \ + $(COMMON_BIN_PATH)/keel-version-files "$$release_version" $O/root.patched || exit 1; \ else \ echo; \ echo "WARNING: can't tag local release (./changelog doesn't exist)"; \ diff --git a/mk/turnkey.mk b/mk/turnkey.mk index 2b475863..3a6fcddb 100644 --- a/mk/turnkey.mk +++ b/mk/turnkey.mk @@ -27,6 +27,9 @@ COMMON_REMOVELISTS += turnkey COMMON_REMOVELISTS_FINAL += turnkey FAB_SHARE_PATH ?= /usr/share/fab +# This repository, as the build sees it. bin/keel-version-files writes the +# two identity files of the image (decision 0014). +COMMON_BIN_PATH ?= $(FAB_PATH)/common/bin APT_OVERLAY = fab-apply-overlay $(COMMON_OVERLAYS_PATH)/bootstrap_apt $O/bootstrap; @@ -51,7 +54,8 @@ define _bootstrap/post endef bootstrap/post += $(_bootstrap/post) -# set /etc/turnkey_version +# set /etc/turnkey_version and /etc/keel_version (bin/keel-version-files, +# decision 0014) # # fab's release meta package (turnkey--) is no longer built: # keel-core is the meta package of a Keel image (handbook decision 0047), @@ -65,11 +69,12 @@ bootstrap/post += $(_bootstrap/post) define _root.patched/post # - # setting /etc/turnkey_version + # setting /etc/turnkey_version and /etc/keel_version # @if [ -f ./changelog ]; then \ - turnkey_version=$$($(FAB_SHARE_PATH)/turnkey-version.py --dist=$(CODENAME) --tag=$(VERSION_TAG) ./changelog $(FAB_ARCH)); \ - echo $$turnkey_version > $O/root.patched/etc/turnkey_version; \ + release_version=$$($(FAB_SHARE_PATH)/turnkey-version.py --dist=$(CODENAME) --tag=$(VERSION_TAG) ./changelog $(FAB_ARCH)); \ + [ -x $(COMMON_BIN_PATH)/keel-version-files ] || { echo "ERROR: $(COMMON_BIN_PATH)/keel-version-files is missing or not executable: the common checkout predates the identity files of decision 0014, update it" >&2; exit 1; }; \ + $(COMMON_BIN_PATH)/keel-version-files "$$release_version" $O/root.patched || exit 1; \ else \ echo; \ echo "WARNING: can't tag local release (./changelog doesn't exist)"; \ diff --git a/tests/apt-sources.bats b/tests/apt-sources.bats index a2ee75a0..9a418a76 100644 --- a/tests/apt-sources.bats +++ b/tests/apt-sources.bats @@ -236,9 +236,14 @@ fetch_hosts() { run ! grep -qE "^$name([[:space:]]|\$)" "$REPO/plans/turnkey/base" done run ! grep -q 'make-release-deb' "$REPO/mk/turnkey.mk" - # the compatibility file is still written (decision 0014) - grep -q 'turnkey_version=.*turnkey-version.py' "$REPO/mk/turnkey.mk" - grep -q '> \$O/root.patched/etc/turnkey_version' "$REPO/mk/turnkey.mk" + # the compatibility file is still written (decision 0014), by + # bin/keel-version-files beside /etc/keel_version, from the version + # turnkey-version.py derives; tests/mk-identity.bats makes the recipe + # against stubs of fab and reads both files back + grep -q 'release_version=.*turnkey-version.py' "$REPO/mk/turnkey.mk" + # the $ are make's, matched literally + # shellcheck disable=SC2016 + grep -q 'keel-version-files "\$\$release_version" \$O/root.patched' "$REPO/mk/turnkey.mk" } # ------------------------------------------------ the security-only upgrade diff --git a/tests/coverage.sh b/tests/coverage.sh index 5062e1b1..c0df0ec2 100755 --- a/tests/coverage.sh +++ b/tests/coverage.sh @@ -37,6 +37,8 @@ targets=( "overlays/turnkey.d/resolvconf-ifupdown-ng/etc/network/if-down.d/resolvconf-ifupdown-ng:tests/resolvconf-ifupdown-ng.bats" "packages/coraza/state:tests/coraza-state.bats" "packages/anubis/signing-key:tests/anubis-signing-key.bats" + "lib/version-files.sh:tests/version-files.bats" + "bin/keel-version-files:tests/version-files.bats" ) for tool in kcov bats; do @@ -75,6 +77,18 @@ done # and pam-unix.bats pins the pam_unix behaviour the others rest on. bats "$root/tests/before-firstboot.bats" "$root/tests/pam-unix.bats" +# Suites whose subject kcov cannot measure: the make recipes of mk/, run by +# make against stubs of fab. They must pass; they contribute no percentage. +unmeasured=( + tests/mk-identity.bats +) +for suite in "${unmeasured[@]}"; do + if ! bats "$root/$suite"; then + echo "$suite: failed" >&2 + failed=1 + fi +done + if [ "$failed" -ne 0 ]; then exit 1 fi diff --git a/tests/mk-identity.bats b/tests/mk-identity.bats new file mode 100644 index 00000000..8e63b35b --- /dev/null +++ b/tests/mk-identity.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# The root.patched/post recipe of mk/turnkey.mk and mk/turnkey-desktop.mk, +# run by make rather than read: the step that writes the two identity files +# of an image (decision 0014) through bin/keel-version-files. +# +# fab is replaced by stubs under tests/mk/: an empty product.mk, a +# turnkey-version.py that prints the version string a build would derive, +# a make-release-deb.py and a fab-chroot that only log. What is real is the +# makefile text under test, make, bin/keel-version-files, and +# mk/turnkey/seal-root, the last step of the same recipe, reached through +# the FAB_PATH the recipe names it by; the scratch root ships root locked, +# as an image must (common#31). + +bats_require_minimum_version 1.5.0 + +setup() { + ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + STUBS="$BATS_TEST_DIRNAME/mk" + WORK="$BATS_TEST_TMPDIR/product" + OUT="$BATS_TEST_TMPDIR/build" + FAB="$BATS_TEST_TMPDIR/fab" + mkdir -p "$WORK" "$OUT/root.patched/etc/apt/apt.conf.d" "$FAB/common/mk/turnkey" + ln -s "$ROOT/mk/turnkey/seal-root" "$FAB/common/mk/turnkey/seal-root" + printf 'root:*:20718:0:99999:7:::\n' > "$OUT/root.patched/etc/shadow" + : > "$WORK/changelog" + export PATH="$STUBS:$PATH" + export KEEL_TEST_RELEASE=turnkey-core-19.0-trixie-amd64 +} + +# run_post MAKEFILE [VAR=VALUE ...]: make the root.patched/post recipe of +# one of the two shared makefiles, from a product directory. +run_post() { + local mk=$1 + shift + run make --no-print-directory -C "$WORK" -f "$STUBS/harness.mk" \ + MK="$ROOT/mk/$mk" FAB_SHARE_PATH="$STUBS" O="$OUT" \ + FAB_PATH="$FAB" COMMON_BIN_PATH="$ROOT/bin" \ + CODENAME=trixie FAB_ARCH=amd64 "$@" post +} + +@test "turnkey.mk writes both identity files" { + run_post turnkey.mk + [ "$status" -eq 0 ] + [ "$(cat "$OUT/root.patched/etc/turnkey_version")" = turnkey-core-19.0-trixie-amd64 ] + [ "$(cat "$OUT/root.patched/etc/keel_version")" = keel-core-19.0-trixie-amd64 ] +} + +@test "turnkey.mk still seals root last: the build date is stamped after the identity files" { + run_post turnkey.mk + [ "$status" -eq 0 ] + [ "$(cat "$OUT/root.patched/etc/keel/build-date")" = "$(date -u +%F)" ] + [ -s "$OUT/root.patched/etc/keel_version" ] +} + +@test "turnkey.mk writes no per-appliance apt User-Agent (common#6)" { + # the identity files used to come with /etc/apt/apt.conf.d/01turnkey, + # which told every archive which appliance this is; the overlay's 01keel + # is the header now, and conf/turnkey.d/apt-identity removes a 01turnkey + run_post turnkey.mk + [ "$status" -eq 0 ] + [ -z "$(ls -A "$OUT/root.patched/etc/apt/apt.conf.d")" ] + run_post turnkey-desktop.mk + [ "$status" -eq 0 ] + [ -z "$(ls -A "$OUT/root.patched/etc/apt/apt.conf.d")" ] +} + +@test "turnkey.mk: a root that ships a password fails the build after the identity files" { + # a yescrypt field, literal: the $ are the hash's, not the shell's + # shellcheck disable=SC2016 + printf 'root:$y$j9T$abcdefghijklmnop$qrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123:20718:0:99999:7:::\n' \ + > "$OUT/root.patched/etc/shadow" + run_post turnkey.mk + [ "$status" -ne 0 ] + [[ "$output" == *"root has a password"* ]] + [ ! -e "$OUT/root.patched/etc/keel/build-date" ] +} + +@test "turnkey-desktop.mk writes both identity files too" { + run_post turnkey-desktop.mk + [ "$status" -eq 0 ] + [ "$(cat "$OUT/root.patched/etc/turnkey_version")" = turnkey-core-19.0-trixie-amd64 ] + [ "$(cat "$OUT/root.patched/etc/keel_version")" = keel-core-19.0-trixie-amd64 ] +} + +@test "a keel- changelog name still gives the compatibility file its prefix" { + KEEL_TEST_RELEASE=keel-core-19.0-trixie-amd64 run_post turnkey.mk + [ "$status" -eq 0 ] + [ "$(cat "$OUT/root.patched/etc/turnkey_version")" = turnkey-core-19.0-trixie-amd64 ] +} + +@test "turnkey.mk: a missing script stops the build and names the stale checkout" { + run_post turnkey.mk COMMON_BIN_PATH="$BATS_TEST_TMPDIR/nowhere" + [ "$status" -ne 0 ] + [[ "$output" == *"$BATS_TEST_TMPDIR/nowhere/keel-version-files"* ]] + [[ "$output" == *"predates"* ]] + [ ! -e "$OUT/root.patched/etc/turnkey_version" ] +} + +@test "turnkey-desktop.mk: a missing script stops the build and names the stale checkout" { + run_post turnkey-desktop.mk COMMON_BIN_PATH="$BATS_TEST_TMPDIR/nowhere" + [ "$status" -ne 0 ] + [[ "$output" == *"$BATS_TEST_TMPDIR/nowhere/keel-version-files"* ]] + [[ "$output" == *"predates"* ]] +} + +@test "an empty version string is one argument, and is refused as a version" { + KEEL_TEST_RELEASE="" run_post turnkey.mk + [ "$status" -ne 0 ] + [[ "$output" == *"is not an appliance identity"* ]] + [[ "$output" != *"two arguments are required"* ]] +} + +@test "a version string with a space in it is one argument, and is refused as a version" { + KEEL_TEST_RELEASE="turnkey-core 19.0-trixie-amd64" run_post turnkey.mk + [ "$status" -ne 0 ] + [[ "$output" == *"is not an appliance identity"* ]] +} + +@test "without a changelog nothing is written and the build goes on" { + rm "$WORK/changelog" + run_post turnkey.mk + [ "$status" -eq 0 ] + [[ "$output" == *"can't tag local release"* ]] + [ ! -e "$OUT/root.patched/etc/keel_version" ] +} diff --git a/tests/mk/fab-chroot b/tests/mk/fab-chroot new file mode 100755 index 00000000..4a739b4e --- /dev/null +++ b/tests/mk/fab-chroot @@ -0,0 +1,3 @@ +#!/bin/sh +# Stub of fab-chroot: logs, enters nothing. +echo "fab-chroot $*" diff --git a/tests/mk/harness.mk b/tests/mk/harness.mk new file mode 100644 index 00000000..3e3641cd --- /dev/null +++ b/tests/mk/harness.mk @@ -0,0 +1,8 @@ +# Runs the root.patched/post recipe of one shared makefile, MK, the way +# fab's product.mk runs it. FAB_SHARE_PATH points at this directory, so the +# product.mk that MK includes is the empty one here. +include $(MK) + +.PHONY: post +post: + $(root.patched/post) diff --git a/tests/mk/make-release-deb.py b/tests/mk/make-release-deb.py new file mode 100755 index 00000000..8675c11f --- /dev/null +++ b/tests/mk/make-release-deb.py @@ -0,0 +1,3 @@ +#!/bin/sh +# Stub of fab's make-release-deb.py: logs, builds nothing. +echo "make-release-deb.py $*" diff --git a/tests/mk/product.mk b/tests/mk/product.mk new file mode 100644 index 00000000..e69de29b diff --git a/tests/mk/turnkey-version.py b/tests/mk/turnkey-version.py new file mode 100755 index 00000000..5d2bd194 --- /dev/null +++ b/tests/mk/turnkey-version.py @@ -0,0 +1,4 @@ +#!/bin/sh +# Stub of fab's turnkey-version.py: prints the version string a build +# derives from the product changelog, taken from KEEL_TEST_RELEASE. +printf '%s\n' "$KEEL_TEST_RELEASE" diff --git a/tests/version-files.bats b/tests/version-files.bats new file mode 100644 index 00000000..38323765 --- /dev/null +++ b/tests/version-files.bats @@ -0,0 +1,280 @@ +#!/usr/bin/env bats +# Unit tests of lib/version-files.sh and bin/keel-version-files: the two +# identity files an image carries, /etc/turnkey_version (the interface we +# honour) and /etc/keel_version (what we say we are), decision 0014. +# +# Nothing here needs root, a chroot or a build: the script is given a +# version string and a directory, and writes two files into it. + +bats_require_minimum_version 1.5.0 + +setup() { + ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + LIB="$ROOT/lib/version-files.sh" + SCRIPT="$ROOT/bin/keel-version-files" + load ../lib/version-files.sh + SCRATCH="$BATS_TEST_TMPDIR/scratch" + mkdir -p "$SCRATCH" +} + +# the app part of a release version string + +@test "app_version: the turnkey- prefix of a changelog name is dropped" { + run kvf_app_version turnkey-wordpress-19.0-trixie-amd64 + [ "$status" -eq 0 ] + [ "$output" = wordpress-19.0-trixie-amd64 ] +} + +@test "app_version: the keel- prefix of a changelog name is dropped" { + run kvf_app_version keel-core-19.0-trixie-amd64 + [ "$status" -eq 0 ] + [ "$output" = core-19.0-trixie-amd64 ] +} + +@test "app_version: a name with neither prefix is kept whole" { + run kvf_app_version core-19.0-trixie-amd64 + [ "$status" -eq 0 ] + [ "$output" = core-19.0-trixie-amd64 ] +} + +@test "app_version: only one prefix is dropped, never two" { + run kvf_app_version keel-turnkey-core-19.0-trixie-amd64 + [ "$output" = turnkey-core-19.0-trixie-amd64 ] +} + +@test "app_version: an app name that begins with the other prefix survives" { + run kvf_app_version turnkey-keelson-19.0-trixie-amd64 + [ "$output" = keelson-19.0-trixie-amd64 ] +} + +@test "app_version: an empty string stays empty" { + run kvf_app_version "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "app_version: no argument is the same as an empty string" { + run kvf_app_version + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# the one prefix rule both helpers apply + +@test "has_prefix: each product prefix followed by a hyphen" { + kvf_has_prefix turnkey-core-19.0-trixie-amd64 + kvf_has_prefix keel-core-19.0-trixie-amd64 +} + +@test "has_prefix: a prefix without its hyphen is not one" { + run kvf_has_prefix keelson-19.0-trixie-amd64 + [ "$status" -eq 1 ] + run kvf_has_prefix turnkey + [ "$status" -eq 1 ] +} + +@test "has_prefix: an empty string and no argument have none" { + run kvf_has_prefix "" + [ "$status" -eq 1 ] + run kvf_has_prefix + [ "$status" -eq 1 ] +} + +# the grammar of the four fields + +@test "is_app_version: the four fields of an appliance" { + run kvf_is_app_version core-19.0-trixie-amd64 + [ "$status" -eq 0 ] +} + +@test "is_app_version: an app name with hyphens keeps them" { + run kvf_is_app_version nginx-php-fastcgi-19.0-trixie-amd64 + [ "$status" -eq 0 ] +} + +@test "is_app_version: a release tag is part of the version field" { + run kvf_is_app_version core-19.0rc-trixie-amd64 + [ "$status" -eq 0 ] +} + +@test "is_app_version: another architecture" { + run kvf_is_app_version core-19.0-trixie-arm64 + [ "$status" -eq 0 ] +} + +@test "is_app_version: a string missing the architecture is refused" { + run kvf_is_app_version core-19.0-trixie + [ "$status" -eq 1 ] +} + +@test "is_app_version: a version that does not start with a digit is refused" { + run kvf_is_app_version core-nineteen-trixie-amd64 + [ "$status" -eq 1 ] +} + +@test "is_app_version: upper case is refused" { + run kvf_is_app_version Core-19.0-trixie-amd64 + [ "$status" -eq 1 ] +} + +@test "is_app_version: an empty string is refused" { + run kvf_is_app_version "" + [ "$status" -eq 1 ] +} + +@test "is_app_version: no argument is refused" { + run kvf_is_app_version + [ "$status" -eq 1 ] +} + +@test "is_app_version: a prefixed string is refused, the prefix goes first" { + run kvf_is_app_version turnkey-core-19.0-trixie-amd64 + [ "$status" -eq 1 ] +} + +# the two strings + +@test "version_string: the compatibility string and the Keel string" { + run kvf_version_string turnkey core-19.0-trixie-amd64 + [ "$output" = turnkey-core-19.0-trixie-amd64 ] + run kvf_version_string keel core-19.0-trixie-amd64 + [ "$output" = keel-core-19.0-trixie-amd64 ] +} + +# the script + +@test "script: a turnkey changelog name writes both files" { + run "$SCRIPT" turnkey-wordpress-19.0-trixie-amd64 "$SCRATCH" + [ "$status" -eq 0 ] + [ "$(cat "$SCRATCH/etc/turnkey_version")" = turnkey-wordpress-19.0-trixie-amd64 ] + [ "$(cat "$SCRATCH/etc/keel_version")" = keel-wordpress-19.0-trixie-amd64 ] +} + +@test "script: a keel changelog name still writes the compatibility file" { + # The case that motivated the check: keel-core's changelog was renamed + # to keel-core-19.0, and /etc/turnkey_version is parsed by prefix. + run "$SCRIPT" keel-core-19.0-trixie-amd64 "$SCRATCH" + [ "$status" -eq 0 ] + [ "$(cat "$SCRATCH/etc/turnkey_version")" = turnkey-core-19.0-trixie-amd64 ] + [ "$(cat "$SCRATCH/etc/keel_version")" = keel-core-19.0-trixie-amd64 ] +} + +@test "script: the two files differ in the prefix and nothing else" { + "$SCRIPT" keel-nginx-php-fastcgi-19.0-trixie-amd64 "$SCRATCH" + turnkey=$(cat "$SCRATCH/etc/turnkey_version") + keel=$(cat "$SCRATCH/etc/keel_version") + [ "${turnkey#turnkey-}" = "${keel#keel-}" ] +} + +@test "script: etc is created when the tree does not have it yet" { + [ ! -d "$SCRATCH/etc" ] + run "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH" + [ "$status" -eq 0 ] + [ -d "$SCRATCH/etc" ] +} + +@test "script: each file is one line, world readable" { + "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH" + [ "$(wc -l < "$SCRATCH/etc/turnkey_version")" -eq 1 ] + [ "$(wc -l < "$SCRATCH/etc/keel_version")" -eq 1 ] + [ "$(stat -c %a "$SCRATCH/etc/turnkey_version")" = 644 ] + [ "$(stat -c %a "$SCRATCH/etc/keel_version")" = 644 ] +} + +@test "script: an existing pair is replaced, not appended to" { + mkdir -p "$SCRATCH/etc" + printf 'turnkey-core-18.0-bookworm-amd64\n' > "$SCRATCH/etc/turnkey_version" + printf 'keel-core-18.0-bookworm-amd64\n' > "$SCRATCH/etc/keel_version" + "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH" + [ "$(cat "$SCRATCH/etc/turnkey_version")" = turnkey-core-19.0-trixie-amd64 ] + [ "$(wc -l < "$SCRATCH/etc/turnkey_version")" -eq 1 ] +} + +@test "script: a version string it cannot name is refused and nothing is written" { + run "$SCRIPT" not-a-version "$SCRATCH" + [ "$status" -eq 1 ] + [[ $output == *"not-a-version"* ]] + [[ $output == *"app-version-codename-architecture"* ]] + [ ! -e "$SCRATCH/etc/turnkey_version" ] + [ ! -e "$SCRATCH/etc/keel_version" ] +} + +@test "script: an empty version string is refused" { + run "$SCRIPT" "" "$SCRATCH" + [ "$status" -eq 1 ] +} + +@test "script: too few arguments print the usage and fail" { + run "$SCRIPT" turnkey-core-19.0-trixie-amd64 + [ "$status" -eq 1 ] + [[ $output == *"usage: keel-version-files"* ]] +} + +@test "script: too many arguments print the usage and fail" { + run "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH" extra + [ "$status" -eq 1 ] + [[ $output == *"usage: keel-version-files"* ]] +} + +@test "script: a hyphenated release tag is refused, and the usage says why" { + run "$SCRIPT" turnkey-core-19.0-rc1-trixie-amd64 "$SCRATCH" + [ "$status" -eq 1 ] + run "$SCRIPT" -h + [[ $output == *"VERSION_TAG"* ]] + [[ $output == *"no hyphen"* ]] +} + +@test "script: -h prints the usage and succeeds" { + run "$SCRIPT" -h + [ "$status" -eq 0 ] + [[ $output == *"usage: keel-version-files"* ]] + run "$SCRIPT" --help + [ "$status" -eq 0 ] +} + +@test "script: a root that is not a directory is refused" { + run "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH/absent" + [ "$status" -eq 2 ] + [[ $output == *"$SCRATCH/absent"* ]] + [[ $output == *"not a directory"* ]] +} + +@test "script: a tree it cannot write to is refused" { + mkdir -p "$SCRATCH/etc" + chmod 0500 "$SCRATCH/etc" + run "$SCRIPT" turnkey-core-19.0-trixie-amd64 "$SCRATCH" + chmod 0700 "$SCRATCH/etc" + [ "$status" -eq 2 ] + [[ $output == *"cannot write"* ]] +} + +@test "script: the library it needs is named in the failure when it is gone" { + copy="$BATS_TEST_TMPDIR/keel-version-files" + cp "$SCRIPT" "$copy" + run "$copy" turnkey-core-19.0-trixie-amd64 "$SCRATCH" + [ "$status" -eq 3 ] + [[ $output == *"version-files.sh"* ]] +} + +# the library under the caller's own shell options (docs/traps.md, "A bats +# suite cannot see a library that kills its caller"): bats turns errexit +# off, the script that sources this library does not. + +@test "library: sourced under set -euo pipefail, a refusal does not kill the caller" { + cat > "$BATS_TEST_TMPDIR/caller" <