From f81a23014a6dbe12128d646c7f023a5bf7f40f62 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 15 Jun 2026 11:36:04 +0200 Subject: [PATCH 1/3] packaging: add package definitions, build script, and test suite --- .github/workflows/test.yml | 78 ++++++++++++++ .gitignore | 4 +- packages/hostd/package.env | 5 + packages/renterd/package.env | 5 + packages/s3d/package.env | 7 ++ packages/walletd/package.env | 5 + scripts/build-deb.sh | 124 ++++++++++++++++++++++ scripts/legacy-unit.sh | 28 +++++ scripts/templates/control.tmpl | 10 ++ scripts/templates/copyright.tmpl | 26 +++++ scripts/templates/postinst.tmpl | 64 +++++++++++ scripts/templates/postrm.tmpl | 45 ++++++++ scripts/templates/preinst.tmpl | 32 ++++++ scripts/templates/prerm.tmpl | 20 ++++ scripts/templates/service.tmpl | 19 ++++ tests/Dockerfile.systemd | 13 +++ tests/README.md | 38 +++++++ tests/build-legacy-deb.sh | 71 +++++++++++++ tests/container-prep.sh | 18 ++++ tests/lib.sh | 37 +++++++ tests/lintian-suppressions.txt | 9 ++ tests/run-tests.sh | 82 ++++++++++++++ tests/scenarios/01-fresh-install.sh | 36 +++++++ tests/scenarios/02-upgrade.sh | 22 ++++ tests/scenarios/03-config-preservation.sh | 28 +++++ tests/scenarios/04-legacy-migration.sh | 85 +++++++++++++++ tests/scenarios/05-remove-purge.sh | 33 ++++++ tests/scenarios/06-lintian.sh | 19 ++++ tests/scenarios/helpers.sh | 84 +++++++++++++++ tests/stub/main.go | 21 ++++ 30 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 packages/hostd/package.env create mode 100644 packages/renterd/package.env create mode 100644 packages/s3d/package.env create mode 100644 packages/walletd/package.env create mode 100755 scripts/build-deb.sh create mode 100644 scripts/legacy-unit.sh create mode 100644 scripts/templates/control.tmpl create mode 100644 scripts/templates/copyright.tmpl create mode 100644 scripts/templates/postinst.tmpl create mode 100644 scripts/templates/postrm.tmpl create mode 100644 scripts/templates/preinst.tmpl create mode 100644 scripts/templates/prerm.tmpl create mode 100644 scripts/templates/service.tmpl create mode 100644 tests/Dockerfile.systemd create mode 100644 tests/README.md create mode 100755 tests/build-legacy-deb.sh create mode 100755 tests/container-prep.sh create mode 100755 tests/lib.sh create mode 100644 tests/lintian-suppressions.txt create mode 100755 tests/run-tests.sh create mode 100755 tests/scenarios/01-fresh-install.sh create mode 100755 tests/scenarios/02-upgrade.sh create mode 100755 tests/scenarios/03-config-preservation.sh create mode 100755 tests/scenarios/04-legacy-migration.sh create mode 100755 tests/scenarios/05-remove-purge.sh create mode 100755 tests/scenarios/06-lintian.sh create mode 100755 tests/scenarios/helpers.sh create mode 100644 tests/stub/main.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..f7c6b3d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,78 @@ +name: Test + +on: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: shellcheck + run: shellcheck -x scripts/build-deb.sh tests/run-tests.sh tests/lib.sh tests/container-prep.sh tests/build-legacy-deb.sh tests/scenarios/*.sh + + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26" + - run: go vet ./... + - run: go test ./... + + build-packages: + name: Build all packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26" + - name: Build stub binaries + run: | + for arch in amd64 arm64; do + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags '-s -w' -o "dist/stub-$arch" ./tests/stub + done + - name: Build every package definition + run: | + for env in packages/*/package.env; do + project=$(basename "$(dirname "$env")") + for arch in amd64 arm64; do + ./scripts/build-deb.sh --project "$project" --version 0.0.1 --arch "$arch" --binary "dist/stub-$arch" --output dist + done + done + ls -l dist/*.deb + - name: Assert package contents are owned by root + run: | + # the runner is not root, so this catches a dropped + # --root-owner-group in build-deb.sh that other gates miss because + # they build debs as root inside the test containers + for deb in dist/*.deb; do + if dpkg-deb -c "$deb" | awk '{print $2}' | grep -vqx 'root/root'; then + echo "non root owner in $deb" >&2 + dpkg-deb -c "$deb" >&2 + exit 1 + fi + done + + test-packages: + name: Test packages + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # oldest and newest of each distro bracket the systemd and dpkg + # version spread + image: + - debian:bullseye + - debian:trixie + - ubuntu:jammy + - ubuntu:questing + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26" + - name: Run packaging tests + run: tests/run-tests.sh --image ${{ matrix.image }} --project hostd diff --git a/.gitignore b/.gitignore index 496ee2ca..64bdf679 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.DS_Store \ No newline at end of file +.DS_Store +dist/ +*.deb diff --git a/packages/hostd/package.env b/packages/hostd/package.env new file mode 100644 index 00000000..64c1feae --- /dev/null +++ b/packages/hostd/package.env @@ -0,0 +1,5 @@ +# package settings for hostd, read by scripts/build-deb.sh +PKG_NAME=hostd +PKG_DESCRIPTION="host daemon for the Sia network" +PKG_LONG_DESCRIPTION="hostd is the next generation Sia host daemon. It connects storage to the Sia network and manages contracts, pricing, and sector storage." +PKG_HOMEPAGE=https://github.com/SiaFoundation/hostd diff --git a/packages/renterd/package.env b/packages/renterd/package.env new file mode 100644 index 00000000..d0447353 --- /dev/null +++ b/packages/renterd/package.env @@ -0,0 +1,5 @@ +# package settings for renterd, read by scripts/build-deb.sh +PKG_NAME=renterd +PKG_DESCRIPTION="renter daemon for the Sia network" +PKG_LONG_DESCRIPTION="renterd is the Sia renter daemon. It forms contracts with hosts on the Sia network and stores data with erasure coded redundancy." +PKG_HOMEPAGE=https://github.com/SiaFoundation/renterd diff --git a/packages/s3d/package.env b/packages/s3d/package.env new file mode 100644 index 00000000..6a7b3bc4 --- /dev/null +++ b/packages/s3d/package.env @@ -0,0 +1,7 @@ +# package settings for s3d, read by scripts/build-deb.sh +PKG_NAME=s3d +# debian package name. binary, service, and data dir still use PKG_NAME +PKG_DEBIAN_NAME=sia-s3d +PKG_DESCRIPTION="S3-compatible gateway for the Sia network" +PKG_LONG_DESCRIPTION="s3d translates the AWS S3 API to the Sia storage network. Applications that speak S3 can store data on Sia without modification." +PKG_HOMEPAGE=https://github.com/SiaFoundation/s3d diff --git a/packages/walletd/package.env b/packages/walletd/package.env new file mode 100644 index 00000000..27f29bf6 --- /dev/null +++ b/packages/walletd/package.env @@ -0,0 +1,5 @@ +# package settings for walletd, read by scripts/build-deb.sh +PKG_NAME=walletd +PKG_DESCRIPTION="wallet daemon for the Sia network" +PKG_LONG_DESCRIPTION="walletd is the Sia wallet daemon. It manages addresses and transactions on the Sia network and serves a wallet API." +PKG_HOMEPAGE=https://github.com/SiaFoundation/walletd diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 00000000..7cb7b7c7 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# builds a .deb for one Sia daemon from an already-built linux binary. +# package settings come from packages//package.env. +# the workflow and tests both use this, so they build the same thing. +set -euo pipefail + +usage() { + echo "usage: $0 --project --version --arch --binary --output " >&2 + exit 1 +} + +PROJECT='' VERSION='' ARCH='' BINARY='' OUTPUT='' +while [ $# -gt 0 ]; do + case "$1" in + --project) PROJECT=$2; shift 2 ;; + --version) VERSION=$2; shift 2 ;; + --arch) ARCH=$2; shift 2 ;; + --binary) BINARY=$2; shift 2 ;; + --output) OUTPUT=$2; shift 2 ;; + *) usage ;; + esac +done +if [ -z "$PROJECT" ] || [ -z "$VERSION" ] || [ -z "$ARCH" ] || [ -z "$BINARY" ] || [ -z "$OUTPUT" ]; then + usage +fi + +case "$ARCH" in +amd64|arm64) ;; +*) echo "unsupported architecture: $ARCH" >&2; exit 1 ;; +esac +[ -f "$BINARY" ] || { echo "binary not found: $BINARY" >&2; exit 1; } + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PKG_DIR=$ROOT/packages/$PROJECT +TEMPLATES=$ROOT/scripts/templates + +[ -f "$PKG_DIR/package.env" ] || { echo "missing $PKG_DIR/package.env, add a package definition first" >&2; exit 1; } +# shellcheck source=/dev/null +. "$PKG_DIR/package.env" +: "${PKG_NAME:?package.env must set PKG_NAME}" +: "${PKG_DESCRIPTION:?package.env must set PKG_DESCRIPTION}" +: "${PKG_LONG_DESCRIPTION:?package.env must set PKG_LONG_DESCRIPTION}" +: "${PKG_HOMEPAGE:?package.env must set PKG_HOMEPAGE}" +[ "$PKG_NAME" = "$PROJECT" ] || { echo "PKG_NAME '$PKG_NAME' does not match project '$PROJECT'" >&2; exit 1; } +# apt package name. +# the installed binary, service, and data dir still use PKG_NAME. +# defaults to PKG_NAME unless package.env overrides it. +: "${PKG_DEBIAN_NAME:=$PKG_NAME}" + +# preinst needs hashes for old stock units. +# ignore Description because the old workflow used free text there. +# the unit text lives in one shared file so tests and packages use the same bytes. +# shellcheck source=scripts/legacy-unit.sh +. "$ROOT/scripts/legacy-unit.sh" +LEGACY_MD5_A=$(legacy_unit "$PKG_NAME" "" a | md5sum | cut -d' ' -f1) +LEGACY_MD5_B=$(legacy_unit "$PKG_NAME" "" b | md5sum | cut -d' ' -f1) +PKG_LEGACY_MD5S="\"$LEGACY_MD5_A\"|\"$LEGACY_MD5_B\"" + +# control files need wrapped long descriptions. +# continuation lines start with a space; empty lines become ".". +PKG_LONG_DESCRIPTION=$(printf '%s\n' "$PKG_LONG_DESCRIPTION" | fold -s -w 76 | sed -e 's/[[:space:]]*$//' -e 's/^$/./' -e 's/^/ /') + +# quote replacements so bash treats &, backslashes, and other chars as plain text. +render() { + local content + content=$(cat "$1") + content=${content//@PKG_NAME@/"$PKG_NAME"} + content=${content//@PKG_DEBIAN_NAME@/"$PKG_DEBIAN_NAME"} + content=${content//@PKG_VERSION@/"$VERSION"} + content=${content//@PKG_ARCH@/"$ARCH"} + content=${content//@PKG_DESCRIPTION@/"$PKG_DESCRIPTION"} + content=${content//@PKG_LONG_DESCRIPTION@/"$PKG_LONG_DESCRIPTION"} + content=${content//@PKG_HOMEPAGE@/"$PKG_HOMEPAGE"} + content=${content//@PKG_INSTALLED_SIZE@/"${PKG_INSTALLED_SIZE:-0}"} + content=${content//@PKG_LEGACY_MD5S@/"$PKG_LEGACY_MD5S"} + printf '%s\n' "$content" +} + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +chmod 755 "$STAGE" + +# payload +install -Dm755 "$BINARY" "$STAGE/usr/bin/$PKG_NAME" + +# a package can override this by adding packages/

/

.service. +install -dm755 "$STAGE/usr/lib/systemd/system" +if [ -f "$PKG_DIR/$PKG_NAME.service" ]; then + install -m644 "$PKG_DIR/$PKG_NAME.service" "$STAGE/usr/lib/systemd/system/$PKG_NAME.service" +else + render "$TEMPLATES/service.tmpl" > "$STAGE/usr/lib/systemd/system/$PKG_NAME.service" + chmod 644 "$STAGE/usr/lib/systemd/system/$PKG_NAME.service" +fi + +# the daemon owns config via " config". +# the package ships no config, so dpkg never overwrites it. +install -dm755 "$STAGE/var/lib/$PKG_NAME" + +install -dm755 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME" +render "$TEMPLATES/copyright.tmpl" > "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/copyright" +chmod 644 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/copyright" +{ + printf '%s (%s) stable; urgency=medium\n\n' "$PKG_DEBIAN_NAME" "$VERSION" + printf ' * Packaged upstream release %s.\n\n' "$VERSION" + printf ' -- The Sia Foundation %s\n' "$(date -R -u)" +} | gzip -9n > "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/changelog.gz" +chmod 644 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/changelog.gz" + +# control +PKG_INSTALLED_SIZE=$(du -sk "$STAGE" | cut -f1) +install -dm755 "$STAGE/DEBIAN" +render "$TEMPLATES/control.tmpl" > "$STAGE/DEBIAN/control" +for script in preinst postinst prerm postrm; do + render "$TEMPLATES/$script.tmpl" > "$STAGE/DEBIAN/$script" + chmod 755 "$STAGE/DEBIAN/$script" +done +(cd "$STAGE" && find . -type f -not -path './DEBIAN/*' | LC_ALL=C sort | sed 's|^\./||' | xargs md5sum > DEBIAN/md5sums) +chmod 644 "$STAGE/DEBIAN/md5sums" "$STAGE/DEBIAN/control" + +# use xz so bullseye can install the package. +# newer dpkg may default to zstd, which bullseye cannot unpack. +mkdir -p "$OUTPUT" +dpkg-deb --root-owner-group -Zxz --build "$STAGE" "$OUTPUT/${PKG_DEBIAN_NAME}_${VERSION}_${ARCH}.deb" > /dev/null +echo "built $OUTPUT/${PKG_DEBIAN_NAME}_${VERSION}_${ARCH}.deb" diff --git a/scripts/legacy-unit.sh b/scripts/legacy-unit.sh new file mode 100644 index 00000000..26428de6 --- /dev/null +++ b/scripts/legacy-unit.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# one copy of the old systemd unit. +# build-deb.sh hashes it for migrations. +# tests/build-legacy-deb.sh writes it into the fake old package. +# keeping one copy means the hash and test package cannot disagree. +# +# empty desc means no Description line. +# preinst ignores Description because the old workflow got it from free text. +# variant a is older; variant b adds TimeoutStopSec. +legacy_unit() { + local name=$1 desc=$2 variant=$3 + printf '%s\n' '[Unit]' + [ -n "$desc" ] && printf 'Description=%s\n' "$desc" + printf '%s\n' \ + '' \ + 'After=network.target' \ + '[Service]' \ + "ExecStart=/usr/bin/$name" \ + "WorkingDirectory=/var/lib/$name" \ + 'Restart=always' \ + 'RestartSec=15' + [ "$variant" = b ] && printf 'TimeoutStopSec=120\n' + printf '%s\n' \ + '' \ + '[Install]' \ + 'WantedBy=multi-user.target' \ + "Alias=$name.service" +} diff --git a/scripts/templates/control.tmpl b/scripts/templates/control.tmpl new file mode 100644 index 00000000..d2b156f8 --- /dev/null +++ b/scripts/templates/control.tmpl @@ -0,0 +1,10 @@ +Package: @PKG_DEBIAN_NAME@ +Version: @PKG_VERSION@ +Architecture: @PKG_ARCH@ +Maintainer: The Sia Foundation +Installed-Size: @PKG_INSTALLED_SIZE@ +Section: net +Priority: optional +Homepage: @PKG_HOMEPAGE@ +Description: @PKG_DESCRIPTION@ +@PKG_LONG_DESCRIPTION@ diff --git a/scripts/templates/copyright.tmpl b/scripts/templates/copyright.tmpl new file mode 100644 index 00000000..833bd2cf --- /dev/null +++ b/scripts/templates/copyright.tmpl @@ -0,0 +1,26 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: @PKG_NAME@ +Source: @PKG_HOMEPAGE@ + +Files: * +Copyright: 2024-2026 The Sia Foundation +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. diff --git a/scripts/templates/postinst.tmpl b/scripts/templates/postinst.tmpl new file mode 100644 index 00000000..18107b21 --- /dev/null +++ b/scripts/templates/postinst.tmpl @@ -0,0 +1,64 @@ +#!/bin/sh +set -e + +PKG=@PKG_NAME@ +UNIT=@PKG_NAME@.service +LEGACY_UNIT=/etc/systemd/system/$UNIT +MARKER=/run/sia-linux/$PKG.legacy-upgrade + +# config exists if the daemon wrote it in either supported place. +config_present() { + [ -f "/var/lib/$PKG/$PKG.yml" ] || [ -f "/etc/$PKG/$PKG.yml" ] +} + +case "$1" in +configure) + # put back a user-edited old unit. + # keeping it in /etc makes it override our packaged unit. + if [ -e "$LEGACY_UNIT.dpkg-bak" ] && [ ! -e "$LEGACY_UNIT" ]; then + mv "$LEGACY_UNIT.dpkg-bak" "$LEGACY_UNIT" + echo "$PKG: preserved your modified $LEGACY_UNIT" >&2 + echo "$PKG: it overrides the packaged unit in /usr/lib/systemd/system" >&2 + echo "$PKG: consider moving your changes to a drop-in via 'systemctl edit $PKG'" >&2 + echo "$PKG: and removing $LEGACY_UNIT to use the packaged unit" >&2 + fi + + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + + if [ -e "$MARKER" ]; then + # first upgrade from the old layout. + # we cannot know if it used to run, so only start it when config exists. + if config_present; then + systemctl enable "$UNIT" 2>/dev/null || true + # old systemd cannot enable this preserved unit. + # create the wants link ourselves. + if ! systemctl is-enabled --quiet "$UNIT" 2>/dev/null && [ -e "$LEGACY_UNIT" ]; then + mkdir -p /etc/systemd/system/multi-user.target.wants || true + ln -sf "$LEGACY_UNIT" "/etc/systemd/system/multi-user.target.wants/$UNIT" || true + systemctl daemon-reload || true + fi + systemctl restart "$UNIT" || true + fi + elif [ -n "$2" ]; then + # normal upgrade or reinstall: restart if running, do not change enablement. + systemctl try-restart "$UNIT" || true + fi + # fresh installs stay off until the admin creates config. + fi + rm -f "$MARKER" + + # no config on a fresh install: show the setup hint. + if [ -z "${2:-}" ] && ! config_present; then + echo "$PKG: run '$PKG config' to set your wallet seed and password, then 'systemctl enable --now $PKG'" >&2 + fi + + if [ -f "/var/lib/$PKG/$PKG.yml" ]; then + echo "$PKG: note: /var/lib/$PKG/$PKG.yml exists and takes precedence over /etc/$PKG/$PKG.yml" >&2 + fi + ;; +abort-upgrade|abort-remove|abort-deconfigure) + ;; +esac + +exit 0 diff --git a/scripts/templates/postrm.tmpl b/scripts/templates/postrm.tmpl new file mode 100644 index 00000000..0885d4f9 --- /dev/null +++ b/scripts/templates/postrm.tmpl @@ -0,0 +1,45 @@ +#!/bin/sh +set -e + +PKG=@PKG_NAME@ +LEGACY_UNIT=/etc/systemd/system/$PKG.service + +reload_systemd() { + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + fi +} + +case "$1" in +remove) + rm -f "/run/sia-linux/$PKG.legacy-upgrade" + reload_systemd + ;; +upgrade) + # also covers downgrade to old packages, which cannot reload systemd themselves. + reload_systemd + ;; +purge) + rm -f "/run/sia-linux/$PKG.legacy-upgrade" + # cleanup in case prerm missed a service enable symlink. + rm -f "/etc/systemd/system/"*.wants/"$PKG.service" + # old preserved units point at a binary that is gone now. + rm -f "$LEGACY_UNIT" "$LEGACY_UNIT.dpkg-bak" + # purge means wipe logs, config, and data. + rm -rf "/var/log/$PKG" "/etc/$PKG" "/var/lib/$PKG" + reload_systemd + ;; +abort-upgrade) + # failed upgrade rollback: put the hidden unit back for the old package. + if [ -e "$LEGACY_UNIT.dpkg-bak" ] && [ ! -e "$LEGACY_UNIT" ]; then + mv "$LEGACY_UNIT.dpkg-bak" "$LEGACY_UNIT" + fi + rm -f "/run/sia-linux/$PKG.legacy-upgrade" + # old packages have no postinst, so reload systemd here. + reload_systemd + ;; +abort-install|failed-upgrade|disappear) + ;; +esac + +exit 0 diff --git a/scripts/templates/preinst.tmpl b/scripts/templates/preinst.tmpl new file mode 100644 index 00000000..a77874cb --- /dev/null +++ b/scripts/templates/preinst.tmpl @@ -0,0 +1,32 @@ +#!/bin/sh +set -e + +LEGACY_UNIT=/etc/systemd/system/@PKG_NAME@.service +MARKER_DIR=/run/sia-linux + +case "$1" in +upgrade) + # no vendor unit means we are upgrading from the old /etc layout. + # the old prerm already stopped and disabled the service, so postinst decides what to do. + if [ ! -e "/usr/lib/systemd/system/@PKG_NAME@.service" ] && [ -f "$LEGACY_UNIT" ] && [ ! -L "$LEGACY_UNIT" ]; then + mkdir -p "$MARKER_DIR" + : > "$MARKER_DIR/@PKG_NAME@.legacy-upgrade" + + # hash the old unit, ignoring Description because it came from free text. + sum=$(grep -v '^Description=' "$LEGACY_UNIT" | md5sum | cut -d' ' -f1) + case "$sum" in + @PKG_LEGACY_MD5S@) + # stock unit: let dpkg remove it so the new vendor unit takes over. + ;; + *) + # changed unit: hide it during unpack, then postinst puts it back. + mv "$LEGACY_UNIT" "$LEGACY_UNIT.dpkg-bak" + ;; + esac + fi + ;; +install|abort-upgrade) + ;; +esac + +exit 0 diff --git a/scripts/templates/prerm.tmpl b/scripts/templates/prerm.tmpl new file mode 100644 index 00000000..a71b4af1 --- /dev/null +++ b/scripts/templates/prerm.tmpl @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +case "$1" in +remove) + if [ -d /run/systemd/system ]; then + systemctl disable --now @PKG_NAME@.service || true + fi + ;; +upgrade|deconfigure) + # nothing on upgrade. + # postinst restarts it after the new version is unpacked. + ;; +failed-upgrade) + # the old prerm ran systemctl without checking for systemd. + # exit cleanly here so dpkg can keep going. + ;; +esac + +exit 0 diff --git a/scripts/templates/service.tmpl b/scripts/templates/service.tmpl new file mode 100644 index 00000000..b9be0731 --- /dev/null +++ b/scripts/templates/service.tmpl @@ -0,0 +1,19 @@ +[Unit] +Description=@PKG_NAME@: @PKG_DESCRIPTION@ +Documentation=@PKG_HOMEPAGE@ +Wants=network-online.target +After=network-online.target + +[Service] +Type=exec +ExecStart=/usr/bin/@PKG_NAME@ +WorkingDirectory=/var/lib/@PKG_NAME@ +StateDirectory=@PKG_NAME@ +ConfigurationDirectory=@PKG_NAME@ +ConfigurationDirectoryMode=0700 +Restart=always +RestartSec=15 +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/tests/Dockerfile.systemd b/tests/Dockerfile.systemd new file mode 100644 index 00000000..0d5ca9db --- /dev/null +++ b/tests/Dockerfile.systemd @@ -0,0 +1,13 @@ +# test image with systemd. +# plain debian and ubuntu images do not boot an init system. +ARG BASE_IMAGE=debian:bookworm +FROM ${BASE_IMAGE} +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends systemd systemd-sysv logrotate ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# older systemd can wait forever for serial consoles, especially hvc0 on macOS. +RUN systemctl mask getty.target console-getty.service \ + && ln -s /dev/null /etc/systemd/system/serial-getty@.service +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5fafbe04 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,38 @@ +# Packaging tests + +The suite verifies the .deb packages end to end: install layout, conffile +preservation, upgrades, migration from the legacy packages, removal, and +lintian cleanliness. Each scenario runs inside a fresh systemd enabled +container, and the packages under test are built inside the container by the +same `scripts/build-deb.sh` that builds release packages, only the daemon +binary is replaced with a stub. + +## Requirements + +Docker and Go. The host architecture decides the package architecture, so +Apple Silicon machines test the arm64 packages against arm64 images. + +## Running + +```bash +# full suite against one distro +tests/run-tests.sh --image debian:bookworm + +# a single scenario while iterating +tests/run-tests.sh --image ubuntu:jammy --scenario legacy + +# keep the container of a failing scenario for inspection +tests/run-tests.sh --scenario fresh --keep +``` + +If `systemctl is-system-running` never settles inside the container, retry +with `CGROUP_MODE=private tests/run-tests.sh ...`, which starts the container +with a private cgroup namespace instead of the host one. + +## Notes + +- The lintian scenario only runs on `debian:trixie`; older lintian versions + lack the tags and flags it uses. +- `debian:bullseye` reaches end of LTS on 2026-08-31. After that date the + image build needs archive.debian.org sources before `apt-get update` works + again. diff --git a/tests/build-legacy-deb.sh b/tests/build-legacy-deb.sh new file mode 100755 index 00000000..8cc8420e --- /dev/null +++ b/tests/build-legacy-deb.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# builds a fake old .deb for migration tests. +# it keeps the old quirks: unit in /etc, unsafe prerm, no conffiles, no md5sums. +# the unit text matches the old script byte for byte. +# shellcheck disable=SC2129 +set -euo pipefail + +usage() { + echo "usage: $0 --project --version --arch --binary --variant --output

" >&2 + exit 1 +} + +PROJECT='' VERSION='' ARCH='' BINARY='' VARIANT='' OUTPUT='' +while [ $# -gt 0 ]; do + case "$1" in + --project) PROJECT=$2; shift 2 ;; + --version) VERSION=$2; shift 2 ;; + --arch) ARCH=$2; shift 2 ;; + --binary) BINARY=$2; shift 2 ;; + --variant) VARIANT=$2; shift 2 ;; + --output) OUTPUT=$2; shift 2 ;; + *) usage ;; + esac +done +if [ -z "$PROJECT" ] || [ -z "$VERSION" ] || [ -z "$ARCH" ] || [ -z "$BINARY" ] || [ -z "$VARIANT" ] || [ -z "$OUTPUT" ]; then + usage +fi + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=/dev/null +. "$ROOT/packages/$PROJECT/package.env" +DESCRIPTION=$PKG_DESCRIPTION +# shellcheck source=scripts/legacy-unit.sh +. "$ROOT/scripts/legacy-unit.sh" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +chmod 755 "$STAGE" + +mkdir -p "$STAGE/DEBIAN" +mkdir -p "$STAGE/usr/bin" +mkdir -p "$STAGE/var/lib/$PROJECT" +mkdir -p "$STAGE/etc/systemd/system" + +cp "$BINARY" "$STAGE/usr/bin/$PROJECT" +chmod 755 "$STAGE/usr/bin/$PROJECT" + +# control file, mirrors the historical echo block line by line on purpose +# shellcheck disable=SC2129 +echo "Package: $PROJECT" > "$STAGE/DEBIAN/control" +echo "Version: $VERSION" >> "$STAGE/DEBIAN/control" +echo "Architecture: $ARCH" >> "$STAGE/DEBIAN/control" +echo "Maintainer: The Sia Foundation " >> "$STAGE/DEBIAN/control" +echo "Priority: optional" >> "$STAGE/DEBIAN/control" +echo "Section: net" >> "$STAGE/DEBIAN/control" +echo "Description: $DESCRIPTION" >> "$STAGE/DEBIAN/control" +echo "Homepage: https://github.com/SiaFoundation/$PROJECT" >> "$STAGE/DEBIAN/control" + +# use the shared old unit text so the test package and hash stay in sync. +UNIT=$STAGE/etc/systemd/system/$PROJECT.service +legacy_unit "$PROJECT" "$DESCRIPTION" "$VARIANT" > "$UNIT" + +# same unsafe prerm the old packages had. +echo "#!/bin/sh" > "$STAGE/DEBIAN/prerm" +echo "systemctl stop $PROJECT.service" >> "$STAGE/DEBIAN/prerm" +echo "systemctl disable $PROJECT.service" >> "$STAGE/DEBIAN/prerm" +chmod +x "$STAGE/DEBIAN/prerm" + +mkdir -p "$OUTPUT" +dpkg-deb --root-owner-group -Zxz --build "$STAGE" "$OUTPUT/${PROJECT}_${VERSION}_${ARCH}.deb" > /dev/null +echo "built $OUTPUT/${PROJECT}_${VERSION}_${ARCH}.deb" diff --git a/tests/container-prep.sh b/tests/container-prep.sh new file mode 100755 index 00000000..6df99ad9 --- /dev/null +++ b/tests/container-prep.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# runs inside the test container. +# copies the read-only repo to /work and builds the .debs used by scenarios. +set -euo pipefail + +PKG=$1 +ARCH=$2 + +mkdir -p /work +cp -r /src/scripts /src/packages /src/tests /work/ +cp "/src/dist/stub-linux-$ARCH" /work/stub + +for v in 1.0.0 2.0.0 3.0.0; do + /work/scripts/build-deb.sh --project "$PKG" --version "$v" --arch "$ARCH" --binary /work/stub --output /work/dist +done + +/work/tests/build-legacy-deb.sh --project "$PKG" --version 0.9.0 --arch "$ARCH" --binary /work/stub --variant a --output /work/dist +/work/tests/build-legacy-deb.sh --project "$PKG" --version 0.9.1 --arch "$ARCH" --binary /work/stub --variant b --output /work/dist diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100755 index 00000000..ec963b11 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# container helpers for the package tests, loaded by run-tests.sh + +start_container() { + local cgroup_args=(--cgroupns=host -v /sys/fs/cgroup:/sys/fs/cgroup:rw) + if [ "${CGROUP_MODE:-}" = private ]; then + cgroup_args=(--cgroupns=private) + fi + + docker run -d --rm \ + --privileged \ + "${cgroup_args[@]}" \ + --tmpfs /run \ + --tmpfs /tmp \ + -v "$ROOT":/src:ro \ + "$TEST_IMAGE" /sbin/init +} + +wait_for_systemd() { + local cid=$1 + local state + for _ in $(seq 1 90); do + state=$(docker exec "$cid" systemctl is-system-running 2>/dev/null || true) + case "$state" in + running|degraded) return 0 ;; + esac + sleep 1 + done + echo "systemd did not become ready in container $cid, last state: ${state:-unknown}" >&2 + echo "try CGROUP_MODE=private if this persists" >&2 + return 1 +} + +stop_container() { + local cid=$1 + docker rm -f "$cid" > /dev/null 2>&1 || true +} diff --git a/tests/lintian-suppressions.txt b/tests/lintian-suppressions.txt new file mode 100644 index 00000000..e0da939c --- /dev/null +++ b/tests/lintian-suppressions.txt @@ -0,0 +1,9 @@ +# statically linked go binaries are intentional, the daemons embed their +# dependencies +statically-linked-binary +# the daemons ship no man pages yet +no-manual-page +# the packages are built with raw dpkg-deb and have no debhelper tooling, +# maintainer scripts call systemctl directly behind a /run/systemd/system +# guard +maintainer-script-calls-systemctl diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 00000000..2a3220e4 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# package test runner. +# builds the stub daemon, boots one systemd container per scenario, +# builds the debs inside it, then runs the scenario checks. +# +# usage: tests/run-tests.sh [--image debian:bookworm] [--project hostd] +# [--scenario ] [--keep] +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=tests/lib.sh +. "$ROOT/tests/lib.sh" + +IMAGE=debian:bookworm +PROJECT=hostd +FILTER='' +KEEP=0 +while [ $# -gt 0 ]; do + case "$1" in + --image) IMAGE=$2; shift 2 ;; + --project) PROJECT=$2; shift 2 ;; + --scenario) FILTER=$2; shift 2 ;; + --keep) KEEP=1; shift ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +case "$(uname -m)" in +x86_64) ARCH=amd64 ;; +arm64|aarch64) ARCH=arm64 ;; +*) echo "unsupported host architecture: $(uname -m)" >&2; exit 1 ;; +esac + +echo "== building stub daemon for linux/$ARCH" +mkdir -p "$ROOT/dist" +# stripped like the release binaries so lintian sees the same shape +(cd "$ROOT/tests/stub" && CGO_ENABLED=0 GOOS=linux GOARCH=$ARCH go build -trimpath -ldflags '-s -w' -o "$ROOT/dist/stub-linux-$ARCH" .) + +TEST_IMAGE=sia-linux-test:$(echo "$IMAGE" | tr ':/' '--') +echo "== building test image $TEST_IMAGE from $IMAGE" +docker build -q -t "$TEST_IMAGE" --build-arg "BASE_IMAGE=$IMAGE" -f "$ROOT/tests/Dockerfile.systemd" "$ROOT/tests" > /dev/null + +declare -a failed=() +ran=0 +for scenario in "$ROOT"/tests/scenarios/[0-9]*.sh; do + name=$(basename "$scenario") + if [ -n "$FILTER" ] && [[ "$name" != *"$FILTER"* ]]; then + continue + fi + ran=$((ran + 1)) + + echo "== $name ($IMAGE, $PROJECT, $ARCH)" + cid=$(start_container) + if ! wait_for_systemd "$cid"; then + stop_container "$cid" + failed+=("$name (systemd not ready)") + continue + fi + + if docker exec "$cid" bash /src/tests/container-prep.sh "$PROJECT" "$ARCH" \ + && docker exec -e "PKG=$PROJECT" -e "ARCH=$ARCH" "$cid" bash "/work/tests/scenarios/$name"; then + echo "== PASS $name" + else + echo "== FAIL $name" >&2 + failed+=("$name") + if [ "$KEEP" = 1 ]; then + echo "== container kept for debugging: $cid" >&2 + continue + fi + fi + stop_container "$cid" +done + +if [ "$ran" -eq 0 ]; then + echo "no scenario matched filter '$FILTER'" >&2 + exit 1 +fi +if [ "${#failed[@]}" -gt 0 ]; then + echo "failed scenarios: ${failed[*]}" >&2 + exit 1 +fi +echo "all $ran scenarios passed" diff --git a/tests/scenarios/01-fresh-install.sh b/tests/scenarios/01-fresh-install.sh new file mode 100755 index 00000000..8515f2cf --- /dev/null +++ b/tests/scenarios/01-fresh-install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# fresh install: files land in the right places, the package ships no config +# or conffiles, the service stays disabled until the admin opts in +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +install_deb "$(deb 1.0.0)" + +# layout +[ -x "/usr/bin/$PKG" ] || fail "binary missing or not executable" +assert_file "/usr/lib/systemd/system/$PKG.service" +assert_no_path "/etc/systemd/system/$PKG.service" +assert_dir "/var/lib/$PKG" +assert_file "/usr/share/doc/$PKG/copyright" +assert_file "/usr/share/doc/$PKG/changelog.gz" + +# config is made by "$PKG config", not shipped in the package. +assert_no_path "/etc/$PKG/$PKG.yml" +assert_no_path "/etc/logrotate.d/$PKG" + +# no dpkg-managed config files. +conffiles=$(dpkg-query -W -f='${Conffiles}' "$PKG" | tr -d '[:space:]') +[ -z "$conffiles" ] || fail "package should register no conffiles, got: $conffiles" + +# pristine install should verify cleanly. +dpkg -V "$PKG" || fail "dpkg -V reported differences on a pristine install" + +# service stays off by default. +assert_eq "$(unit_state)" disabled "fresh install enablement" +assert_eq "$(active_state)" inactive "fresh install activity" + +# manual enable/start works. +systemctl enable --now "$PKG.service" +sleep 1 +assert_eq "$(unit_state)" enabled +assert_eq "$(active_state)" active diff --git a/tests/scenarios/02-upgrade.sh b/tests/scenarios/02-upgrade.sh new file mode 100755 index 00000000..98a265bd --- /dev/null +++ b/tests/scenarios/02-upgrade.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# upgrade: running service restarts, but enabled/disabled state stays as-is. +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +install_deb "$(deb 1.0.0)" +systemctl enable --now "$PKG.service" +sleep 1 +pid_before=$(main_pid) + +install_deb "$(deb 2.0.0)" +sleep 1 +assert_eq "$(active_state)" active "service must be running after upgrade" +pid_after=$(main_pid) +[ "$pid_before" != "$pid_after" ] || fail "service was not restarted on upgrade" +assert_eq "$(unit_state)" enabled "upgrade must keep the service enabled" + +# disabled service stays disabled and stopped. +systemctl disable --now "$PKG.service" +install_deb "$(deb 3.0.0)" +assert_eq "$(unit_state)" disabled "upgrade must not enable a disabled service" +assert_eq "$(active_state)" inactive "upgrade must not start a stopped service" diff --git a/tests/scenarios/03-config-preservation.sh b/tests/scenarios/03-config-preservation.sh new file mode 100755 index 00000000..f07c92e5 --- /dev/null +++ b/tests/scenarios/03-config-preservation.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# admin config and systemd drop-ins survive upgrades byte for byte. +# the package ships no config, so dpkg should not touch them. +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +install_deb "$(deb 1.0.0)" + +# fake what "$PKG config" and "systemctl edit" would create. +mkdir -p "/etc/$PKG" +printf 'directory: /var/lib/%s\n# local change\n' "$PKG" > "/etc/$PKG/$PKG.yml" +mkdir -p "/etc/systemd/system/$PKG.service.d" +printf '[Service]\nEnvironment=SIA_TEST_OVERRIDE=1\n' > "/etc/systemd/system/$PKG.service.d/override.conf" +systemctl daemon-reload + +sum_config=$(sha256sum "/etc/$PKG/$PKG.yml" | cut -d' ' -f1) +sum_dropin=$(sha256sum "/etc/systemd/system/$PKG.service.d/override.conf" | cut -d' ' -f1) + +systemctl enable --now "$PKG.service" +sleep 1 + +install_deb "$(deb 2.0.0)" +sleep 1 + +assert_eq "$(sha256sum "/etc/$PKG/$PKG.yml" | cut -d' ' -f1)" "$sum_config" "admin config changed during upgrade" +assert_eq "$(sha256sum "/etc/systemd/system/$PKG.service.d/override.conf" | cut -d' ' -f1)" "$sum_dropin" "drop-in changed during upgrade" +systemctl show "$PKG.service" -p Environment | grep -q SIA_TEST_OVERRIDE || fail "drop-in no longer effective after upgrade" +assert_eq "$(active_state)" active diff --git a/tests/scenarios/04-legacy-migration.sh b/tests/scenarios/04-legacy-migration.sh new file mode 100755 index 00000000..df687d06 --- /dev/null +++ b/tests/scenarios/04-legacy-migration.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# migration from old packages. +# they put the unit in /etc/systemd/system and had an unsafe prerm. +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +# case a: stock old unit, configured and enabled. +# new vendor unit takes over and the service comes back. +install_deb "$(deb 0.9.1)" +assert_file "/etc/systemd/system/$PKG.service" +write_config +legacy_enable_now +sleep 1 + +install_deb "$(deb 1.0.0)" +sleep 1 +assert_no_path "/etc/systemd/system/$PKG.service" +assert_no_path "/etc/systemd/system/$PKG.service.dpkg-bak" +assert_vendor_unit_active +assert_eq "$(unit_state)" enabled "migration must re enable a configured install" +assert_eq "$(active_state)" active "migration must restart a configured install" +cleanup_package + +# case a2: older stock unit, before TimeoutStopSec existed. +install_deb "$(deb 0.9.0)" +write_config +legacy_enable_now +install_deb "$(deb 1.0.0)" +sleep 1 +assert_no_path "/etc/systemd/system/$PKG.service" +assert_vendor_unit_active +assert_eq "$(active_state)" active +cleanup_package + +# case a3: changed Description still counts as stock. +# the old workflow got it from free text. +install_deb "$(deb 0.9.1)" +sed -i 's/^Description=.*/Description=my own description/' "/etc/systemd/system/$PKG.service" +systemctl daemon-reload +write_config +legacy_enable_now +install_deb "$(deb 1.0.0)" +sleep 1 +assert_no_path "/etc/systemd/system/$PKG.service" +assert_vendor_unit_active +cleanup_package + +# case b: real unit change. +# keep it in /etc so it overrides the vendor unit. +install_deb "$(deb 0.9.1)" +sed -i 's/^RestartSec=15$/RestartSec=30/' "/etc/systemd/system/$PKG.service" +systemctl daemon-reload +write_config +legacy_enable_now +sleep 1 + +install_deb "$(deb 1.0.0)" +sleep 1 +assert_file "/etc/systemd/system/$PKG.service" +grep -q '^RestartSec=30$' "/etc/systemd/system/$PKG.service" || fail "modified unit content lost" +assert_no_path "/etc/systemd/system/$PKG.service.dpkg-bak" +assert_eq "$(fragment_path)" "/etc/systemd/system/$PKG.service" "preserved unit must override the vendor unit" +assert_eq "$(unit_state)" enabled +assert_eq "$(active_state)" active + +# case d: next upgrade is normal. +# do not run migration again or change enablement, even with a unit in /etc. +systemctl disable --now "$PKG.service" +install_deb "$(deb 2.0.0)" +assert_no_path "/run/sia-linux/$PKG.legacy-upgrade" +assert_file "/etc/systemd/system/$PKG.service" +grep -q '^RestartSec=30$' "/etc/systemd/system/$PKG.service" || fail "preserved unit touched by regular upgrade" +assert_no_path "/etc/systemd/system/$PKG.service.dpkg-bak" +assert_eq "$(unit_state)" disabled "regular upgrade must not re enable" +assert_eq "$(active_state)" inactive +cleanup_package +rm -f "/etc/systemd/system/$PKG.service" +systemctl daemon-reload + +# case c: old install with no config stays off after migration. +install_deb "$(deb 0.9.1)" +install_deb "$(deb 1.0.0)" +assert_file "/usr/lib/systemd/system/$PKG.service" +assert_eq "$(unit_state)" disabled "unconfigured migration must not enable" +assert_eq "$(active_state)" inactive diff --git a/tests/scenarios/05-remove-purge.sh b/tests/scenarios/05-remove-purge.sh new file mode 100755 index 00000000..5a741c27 --- /dev/null +++ b/tests/scenarios/05-remove-purge.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# remove leaves admin config and data; purge deletes all of it. +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +install_deb "$(deb 1.0.0)" +# admin config and data; the package did not ship either. +mkdir -p "/etc/$PKG" +printf 'directory: /var/lib/%s\n# local change\n' "$PKG" > "/etc/$PKG/$PKG.yml" +echo "wallet data" > "/var/lib/$PKG/keep.dat" +systemctl enable --now "$PKG.service" +sleep 1 + +DEBIAN_FRONTEND=noninteractive apt-get remove -y "$PKG" > /dev/null + +# remove deletes package files but keeps config and data. +assert_no_path "/usr/bin/$PKG" +assert_no_path "/usr/lib/systemd/system/$PKG.service" +[ "$(active_state)" != active ] || fail "service still running after remove" +assert_file "/etc/$PKG/$PKG.yml" +assert_file "/var/lib/$PKG/keep.dat" + +# reinstall after remove keeps config and stays disabled. +install_deb "$(deb 1.0.0)" +assert_eq "$(unit_state)" disabled "reinstall must not auto enable" +grep -q '# local change' "/etc/$PKG/$PKG.yml" || fail "config lost across remove and reinstall" + +DEBIAN_FRONTEND=noninteractive apt-get purge -y "$PKG" > /dev/null + +# purge deletes everything. +assert_no_path "/etc/$PKG" +assert_no_path "/var/lib/$PKG" +assert_no_path "/var/log/$PKG" diff --git a/tests/scenarios/06-lintian.sh b/tests/scenarios/06-lintian.sh new file mode 100755 index 00000000..674d19d3 --- /dev/null +++ b/tests/scenarios/06-lintian.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# lintian check. +# only run it on trixie because older lintian lacks needed tags and flags. +# shellcheck source=tests/scenarios/helpers.sh +. /work/tests/scenarios/helpers.sh + +. /etc/os-release +if [ "${VERSION_CODENAME:-}" != trixie ]; then + echo "skipping lintian, only runs on debian trixie" + exit 0 +fi + +apt-get update > /dev/null +DEBIAN_FRONTEND=noninteractive apt-get install -y lintian > /dev/null + +# build the suppression tag list from the file. +tags=$(grep -vE '^(#|$)' /work/tests/lintian-suppressions.txt | paste -sd,) + +lintian --fail-on error,warning ${tags:+--suppress-tags "$tags"} "$(deb 1.0.0)" diff --git a/tests/scenarios/helpers.sh b/tests/scenarios/helpers.sh new file mode 100755 index 00000000..02b64d9d --- /dev/null +++ b/tests/scenarios/helpers.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# helpers loaded by every scenario. +# they run as root in the test container with PKG and ARCH set. +set -euo pipefail + +: "${PKG:?PKG must be set}" +: "${ARCH:?ARCH must be set}" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +deb() { + echo "/work/dist/${PKG}_$1_${ARCH}.deb" +} + +install_deb() { + DEBIAN_FRONTEND=noninteractive apt-get install -y "$1" > /dev/null 2>&1 \ + || fail "failed to install $1" +} + +assert_file() { + [ -f "$1" ] || fail "expected file $1" +} + +assert_dir() { + [ -d "$1" ] || fail "expected directory $1" +} + +assert_no_path() { + [ ! -e "$1" ] || fail "expected $1 to be absent" +} + +assert_eq() { + [ "$1" = "$2" ] || fail "${3:-values differ}: got '$1', want '$2'" +} + +unit_state() { + systemctl is-enabled "$PKG.service" 2>/dev/null || true +} + +active_state() { + systemctl is-active "$PKG.service" 2>/dev/null || true +} + +main_pid() { + systemctl show -p MainPID --value "$PKG.service" +} + +fragment_path() { + systemctl show -p FragmentPath --value "$PKG.service" +} + +# merged usr can show the vendor unit under /lib or /usr/lib. +assert_vendor_unit_active() { + case "$(fragment_path)" in + "/usr/lib/systemd/system/$PKG.service"|"/lib/systemd/system/$PKG.service") ;; + *) fail "vendor unit must be the active fragment, got '$(fragment_path)'" ;; + esac +} + +write_config() { + mkdir -p "/var/lib/$PKG" + echo "# test config" > "/var/lib/$PKG/$PKG.yml" +} + +# systemd before 252 cannot enable the old alias. +# do what admins did: create the wants link and start it. +legacy_enable_now() { + if ! systemctl enable --now "$PKG.service" 2>/dev/null; then + mkdir -p "/etc/systemd/system/multi-user.target.wants" + ln -sf "/etc/systemd/system/$PKG.service" "/etc/systemd/system/multi-user.target.wants/$PKG.service" + systemctl daemon-reload + systemctl start "$PKG.service" + fi +} + +# reset package state between subcases. +cleanup_package() { + DEBIAN_FRONTEND=noninteractive apt-get purge -y "$PKG" > /dev/null 2>&1 || true + rm -rf "/var/lib/$PKG" "/run/sia-linux" + systemctl daemon-reload +} diff --git a/tests/stub/main.go b/tests/stub/main.go new file mode 100644 index 00000000..9ff4601a --- /dev/null +++ b/tests/stub/main.go @@ -0,0 +1,21 @@ +// Package main implements a stub daemon used by the packaging tests. It +// stands in for the real Sia daemons so the test suite can exercise package +// install, upgrade, and removal without network access. +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" +) + +func main() { + fmt.Println("stub daemon started") + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch + + fmt.Println("stub daemon stopped") +} From 3689286d9f557bef25fdee4fe5336a7e994314a9 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 18 Jun 2026 17:09:42 +0200 Subject: [PATCH 2/3] all: address comments --- scripts/build-deb.sh | 14 +++++++++++--- scripts/templates/control.tmpl | 1 + scripts/templates/postinst.tmpl | 4 +++- tests/README.md | 2 +- tests/run-tests.sh | 12 ++++++++++++ tests/scenarios/helpers.sh | 5 +++-- 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index 7cb7b7c7..9b8e8fa2 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -99,12 +99,20 @@ install -dm755 "$STAGE/var/lib/$PKG_NAME" install -dm755 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME" render "$TEMPLATES/copyright.tmpl" > "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/copyright" chmod 644 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/copyright" +# dpkg treats a version containing a hyphen as non native, which must name its +# changelog changelog.Debian.gz; versions without one use changelog.gz. +case "$VERSION" in +*-*) CHANGELOG=changelog.Debian.gz ;; +*) CHANGELOG=changelog.gz ;; +esac +# honor SOURCE_DATE_EPOCH so identical inputs produce a byte identical package +CHANGELOG_DATE=$(date -R -u -d "@${SOURCE_DATE_EPOCH:-$(date +%s)}") { printf '%s (%s) stable; urgency=medium\n\n' "$PKG_DEBIAN_NAME" "$VERSION" printf ' * Packaged upstream release %s.\n\n' "$VERSION" - printf ' -- The Sia Foundation %s\n' "$(date -R -u)" -} | gzip -9n > "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/changelog.gz" -chmod 644 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/changelog.gz" + printf ' -- The Sia Foundation %s\n' "$CHANGELOG_DATE" +} | gzip -9n > "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/$CHANGELOG" +chmod 644 "$STAGE/usr/share/doc/$PKG_DEBIAN_NAME/$CHANGELOG" # control PKG_INSTALLED_SIZE=$(du -sk "$STAGE" | cut -f1) diff --git a/scripts/templates/control.tmpl b/scripts/templates/control.tmpl index d2b156f8..6570e104 100644 --- a/scripts/templates/control.tmpl +++ b/scripts/templates/control.tmpl @@ -3,6 +3,7 @@ Version: @PKG_VERSION@ Architecture: @PKG_ARCH@ Maintainer: The Sia Foundation Installed-Size: @PKG_INSTALLED_SIZE@ +Depends: ca-certificates Section: net Priority: optional Homepage: @PKG_HOMEPAGE@ diff --git a/scripts/templates/postinst.tmpl b/scripts/templates/postinst.tmpl index 18107b21..8b5b6e18 100644 --- a/scripts/templates/postinst.tmpl +++ b/scripts/templates/postinst.tmpl @@ -39,6 +39,8 @@ configure) systemctl daemon-reload || true fi systemctl restart "$UNIT" || true + else + echo "$PKG: service was not restarted because no config was found; run 'systemctl enable --now $PKG' if it should be running" >&2 fi elif [ -n "$2" ]; then # normal upgrade or reinstall: restart if running, do not change enablement. @@ -50,7 +52,7 @@ configure) # no config on a fresh install: show the setup hint. if [ -z "${2:-}" ] && ! config_present; then - echo "$PKG: run '$PKG config' to set your wallet seed and password, then 'systemctl enable --now $PKG'" >&2 + echo "$PKG: run '$PKG config' to configure it, then 'systemctl enable --now $PKG'" >&2 fi if [ -f "/var/lib/$PKG/$PKG.yml" ]; then diff --git a/tests/README.md b/tests/README.md index 5fafbe04..62534635 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Packaging tests -The suite verifies the .deb packages end to end: install layout, conffile +The suite verifies the .deb packages end to end: install layout, config preservation, upgrades, migration from the legacy packages, removal, and lintian cleanliness. Each scenario runs inside a fresh systemd enabled container, and the packages under test are built inside the container by the diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 2a3220e4..f177af92 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -31,6 +31,18 @@ arm64|aarch64) ARCH=arm64 ;; *) echo "unsupported host architecture: $(uname -m)" >&2; exit 1 ;; esac +# the scenarios derive the apt package name and doc dir from the project name, +# so they cannot drive a package whose debian name differs (s3d ships as sia-s3d). +ENV_FILE="$ROOT/packages/$PROJECT/package.env" +if [ -f "$ENV_FILE" ]; then + # shellcheck source=/dev/null + DEBIAN_NAME=$(. "$ENV_FILE"; echo "${PKG_DEBIAN_NAME:-$PKG_NAME}") + if [ "$DEBIAN_NAME" != "$PROJECT" ]; then + echo "scenarios do not support split package names yet ($DEBIAN_NAME != $PROJECT)" >&2 + exit 1 + fi +fi + echo "== building stub daemon for linux/$ARCH" mkdir -p "$ROOT/dist" # stripped like the release binaries so lintian sees the same shape diff --git a/tests/scenarios/helpers.sh b/tests/scenarios/helpers.sh index 02b64d9d..509c8a97 100755 --- a/tests/scenarios/helpers.sh +++ b/tests/scenarios/helpers.sh @@ -16,8 +16,9 @@ deb() { } install_deb() { - DEBIAN_FRONTEND=noninteractive apt-get install -y "$1" > /dev/null 2>&1 \ - || fail "failed to install $1" + local out + out=$(DEBIAN_FRONTEND=noninteractive apt-get install -y "$1" 2>&1) \ + || { echo "$out" >&2; fail "failed to install $1"; } } assert_file() { From 3a2fa68872a57ca5158c924ae07585b85788f46b Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 20 Jun 2026 20:44:02 +0200 Subject: [PATCH 3/3] all: update copy in tests --- scripts/templates/postrm.tmpl | 4 ++-- tests/scenarios/01-fresh-install.sh | 10 ++++----- tests/scenarios/02-upgrade.sh | 2 +- tests/scenarios/03-config-preservation.sh | 2 +- tests/scenarios/04-legacy-migration.sh | 27 +++++++++++------------ tests/scenarios/05-remove-purge.sh | 3 +-- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/scripts/templates/postrm.tmpl b/scripts/templates/postrm.tmpl index 0885d4f9..9f2ce65a 100644 --- a/scripts/templates/postrm.tmpl +++ b/scripts/templates/postrm.tmpl @@ -25,8 +25,8 @@ purge) rm -f "/etc/systemd/system/"*.wants/"$PKG.service" # old preserved units point at a binary that is gone now. rm -f "$LEGACY_UNIT" "$LEGACY_UNIT.dpkg-bak" - # purge means wipe logs, config, and data. - rm -rf "/var/log/$PKG" "/etc/$PKG" "/var/lib/$PKG" + # purge wipes config and data. + rm -rf "/etc/$PKG" "/var/lib/$PKG" reload_systemd ;; abort-upgrade) diff --git a/tests/scenarios/01-fresh-install.sh b/tests/scenarios/01-fresh-install.sh index 8515f2cf..fe0e1666 100755 --- a/tests/scenarios/01-fresh-install.sh +++ b/tests/scenarios/01-fresh-install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# fresh install: files land in the right places, the package ships no config -# or conffiles, the service stays disabled until the admin opts in +# fresh install: correct layout, no shipped config or conffiles, service stays +# off until the admin enables it. # shellcheck source=tests/scenarios/helpers.sh . /work/tests/scenarios/helpers.sh @@ -14,15 +14,15 @@ assert_dir "/var/lib/$PKG" assert_file "/usr/share/doc/$PKG/copyright" assert_file "/usr/share/doc/$PKG/changelog.gz" -# config is made by "$PKG config", not shipped in the package. +# the package ships no config and no logrotate rule; the daemon writes its config. assert_no_path "/etc/$PKG/$PKG.yml" assert_no_path "/etc/logrotate.d/$PKG" -# no dpkg-managed config files. +# dpkg registers no conffiles. conffiles=$(dpkg-query -W -f='${Conffiles}' "$PKG" | tr -d '[:space:]') [ -z "$conffiles" ] || fail "package should register no conffiles, got: $conffiles" -# pristine install should verify cleanly. +# a pristine install verifies cleanly. dpkg -V "$PKG" || fail "dpkg -V reported differences on a pristine install" # service stays off by default. diff --git a/tests/scenarios/02-upgrade.sh b/tests/scenarios/02-upgrade.sh index 98a265bd..b0ce95df 100755 --- a/tests/scenarios/02-upgrade.sh +++ b/tests/scenarios/02-upgrade.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# upgrade: running service restarts, but enabled/disabled state stays as-is. +# upgrade restarts a running service but keeps its enabled/disabled state. # shellcheck source=tests/scenarios/helpers.sh . /work/tests/scenarios/helpers.sh diff --git a/tests/scenarios/03-config-preservation.sh b/tests/scenarios/03-config-preservation.sh index f07c92e5..d3ea1810 100755 --- a/tests/scenarios/03-config-preservation.sh +++ b/tests/scenarios/03-config-preservation.sh @@ -6,7 +6,7 @@ install_deb "$(deb 1.0.0)" -# fake what "$PKG config" and "systemctl edit" would create. +# create the files "$PKG config" and "systemctl edit" would write. mkdir -p "/etc/$PKG" printf 'directory: /var/lib/%s\n# local change\n' "$PKG" > "/etc/$PKG/$PKG.yml" mkdir -p "/etc/systemd/system/$PKG.service.d" diff --git a/tests/scenarios/04-legacy-migration.sh b/tests/scenarios/04-legacy-migration.sh index df687d06..1bf42947 100755 --- a/tests/scenarios/04-legacy-migration.sh +++ b/tests/scenarios/04-legacy-migration.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# migration from old packages. -# they put the unit in /etc/systemd/system and had an unsafe prerm. +# migration from the old packages, which shipped the unit in +# /etc/systemd/system and had an unsafe prerm. # shellcheck source=tests/scenarios/helpers.sh . /work/tests/scenarios/helpers.sh -# case a: stock old unit, configured and enabled. -# new vendor unit takes over and the service comes back. +# stock unit, configured and enabled: the vendor unit takes over and the +# service comes back. install_deb "$(deb 0.9.1)" assert_file "/etc/systemd/system/$PKG.service" write_config @@ -17,11 +17,11 @@ sleep 1 assert_no_path "/etc/systemd/system/$PKG.service" assert_no_path "/etc/systemd/system/$PKG.service.dpkg-bak" assert_vendor_unit_active -assert_eq "$(unit_state)" enabled "migration must re enable a configured install" +assert_eq "$(unit_state)" enabled "migration must enable a configured install" assert_eq "$(active_state)" active "migration must restart a configured install" cleanup_package -# case a2: older stock unit, before TimeoutStopSec existed. +# older stock unit, from before TimeoutStopSec was added. install_deb "$(deb 0.9.0)" write_config legacy_enable_now @@ -32,8 +32,8 @@ assert_vendor_unit_active assert_eq "$(active_state)" active cleanup_package -# case a3: changed Description still counts as stock. -# the old workflow got it from free text. +# a description-only edit still counts as stock; the old workflow set it from +# free text. install_deb "$(deb 0.9.1)" sed -i 's/^Description=.*/Description=my own description/' "/etc/systemd/system/$PKG.service" systemctl daemon-reload @@ -45,8 +45,7 @@ assert_no_path "/etc/systemd/system/$PKG.service" assert_vendor_unit_active cleanup_package -# case b: real unit change. -# keep it in /etc so it overrides the vendor unit. +# a real unit edit is kept in /etc and overrides the vendor unit. install_deb "$(deb 0.9.1)" sed -i 's/^RestartSec=15$/RestartSec=30/' "/etc/systemd/system/$PKG.service" systemctl daemon-reload @@ -63,21 +62,21 @@ assert_eq "$(fragment_path)" "/etc/systemd/system/$PKG.service" "preserved unit assert_eq "$(unit_state)" enabled assert_eq "$(active_state)" active -# case d: next upgrade is normal. -# do not run migration again or change enablement, even with a unit in /etc. +# a later normal upgrade does not migrate again or change enablement, even with +# a unit still in /etc. systemctl disable --now "$PKG.service" install_deb "$(deb 2.0.0)" assert_no_path "/run/sia-linux/$PKG.legacy-upgrade" assert_file "/etc/systemd/system/$PKG.service" grep -q '^RestartSec=30$' "/etc/systemd/system/$PKG.service" || fail "preserved unit touched by regular upgrade" assert_no_path "/etc/systemd/system/$PKG.service.dpkg-bak" -assert_eq "$(unit_state)" disabled "regular upgrade must not re enable" +assert_eq "$(unit_state)" disabled "regular upgrade must not enable" assert_eq "$(active_state)" inactive cleanup_package rm -f "/etc/systemd/system/$PKG.service" systemctl daemon-reload -# case c: old install with no config stays off after migration. +# an unconfigured old install stays off after migration. install_deb "$(deb 0.9.1)" install_deb "$(deb 1.0.0)" assert_file "/usr/lib/systemd/system/$PKG.service" diff --git a/tests/scenarios/05-remove-purge.sh b/tests/scenarios/05-remove-purge.sh index 5a741c27..7efebd32 100755 --- a/tests/scenarios/05-remove-purge.sh +++ b/tests/scenarios/05-remove-purge.sh @@ -4,7 +4,7 @@ . /work/tests/scenarios/helpers.sh install_deb "$(deb 1.0.0)" -# admin config and data; the package did not ship either. +# create admin config and data the package never shipped. mkdir -p "/etc/$PKG" printf 'directory: /var/lib/%s\n# local change\n' "$PKG" > "/etc/$PKG/$PKG.yml" echo "wallet data" > "/var/lib/$PKG/keep.dat" @@ -30,4 +30,3 @@ DEBIAN_FRONTEND=noninteractive apt-get purge -y "$PKG" > /dev/null # purge deletes everything. assert_no_path "/etc/$PKG" assert_no_path "/var/lib/$PKG" -assert_no_path "/var/log/$PKG"