From 55c2dcf869f05065c99d6084255e9150d30350d6 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:03:55 +0300 Subject: [PATCH 01/15] test(hack): add bats tests for host runtime preflight check Add hack/check-host-runtime.bats with six self-contained test cases that exercise the check-host-runtime.sh preflight script: clean host exits silently, standalone containerd.service warns, standalone docker.service warns, both services warn, du failures do not suppress warnings, and the socket-only fallback fires when systemctl is unavailable. Tests inject a stub systemctl and du binary via PATH and redirect the script's probe paths through COZYSTACK_PREFLIGHT_* environment variables, so they run without root and on any host (including non-systemd macOS). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.bats | 214 +++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 hack/check-host-runtime.bats diff --git a/hack/check-host-runtime.bats b/hack/check-host-runtime.bats new file mode 100644 index 00000000..8ae478c9 --- /dev/null +++ b/hack/check-host-runtime.bats @@ -0,0 +1,214 @@ +#!/usr/bin/env bats +# ----------------------------------------------------------------------------- +# Unit tests for hack/check-host-runtime.sh +# +# The script warns when a standalone containerd.service or docker.service is +# active alongside the embedded k3s runtime on Ubuntu hosts running the +# cozystack "generic" variant. Warnings go to stderr; exit code is always 0. +# +# Test strategy: each test builds its own temporary stub directory and prepends +# it to PATH to inject a fake `systemctl` (and optionally `du`) binary. The +# script itself honors a small set of COZYSTACK_PREFLIGHT_* environment +# variables to redirect socket/dir probes into the stub tree, so tests do not +# need root privileges or a real systemd host. +# +# Tests are self-contained — no shared setup/teardown helpers, because +# cozytest.sh's awk parser only recognizes @test blocks and treats a bare `}` +# on its own line as the end of a test function. +# +# Run with: hack/cozytest.sh hack/check-host-runtime.bats +# (or `bats hack/check-host-runtime.bats` if the bats binary is +# installed; cozytest.sh is the CI path.) +# ----------------------------------------------------------------------------- + +@test "clean host with no runtime services exits silently" { + STUB_DIR=$(mktemp -d) + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker1.sock $STUB_DIR/missing-docker2.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh >"$STUB_DIR/stdout" 2>"$STDERR_FILE" + + [ ! -s "$STDERR_FILE" ] + [ ! -s "$STUB_DIR/stdout" ] + + rm -rf "$STUB_DIR" +} + +@test "standalone containerd service active prints warning" { + STUB_DIR=$(mktemp -d) + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ] && [ "$2" = "containerd.service" ]; then + echo active + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + mkdir -p "$STUB_DIR/var-lib-containerd" + echo dummy >"$STUB_DIR/var-lib-containerd/dummy" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/var-lib-containerd" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone containerd.service' "$STDERR_FILE" + if grep -q 'standalone docker.service' "$STDERR_FILE"; then + echo "unexpected docker warning found:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi + + rm -rf "$STUB_DIR" +} + +@test "standalone docker service active prints warning" { + STUB_DIR=$(mktemp -d) + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ] && [ "$2" = "docker.service" ]; then + echo active + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + mkdir -p "$STUB_DIR/var-lib-docker" + echo dummy >"$STUB_DIR/var-lib-docker/dummy" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/var-lib-docker" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone docker.service' "$STDERR_FILE" + if grep -q 'standalone containerd.service' "$STDERR_FILE"; then + echo "unexpected containerd warning found:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi + + rm -rf "$STUB_DIR" +} + +@test "both services active prints two warnings" { + STUB_DIR=$(mktemp -d) + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ]; then + case "$2" in + containerd.service|docker.service) echo active; exit 0 ;; + esac +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + mkdir -p "$STUB_DIR/var-lib-containerd" "$STUB_DIR/var-lib-docker" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/var-lib-containerd" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/var-lib-docker" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone containerd.service' "$STDERR_FILE" + grep -q 'standalone docker.service' "$STDERR_FILE" + + rm -rf "$STUB_DIR" +} + +@test "failing du does not suppress the containerd warning" { + STUB_DIR=$(mktemp -d) + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ] && [ "$2" = "containerd.service" ]; then + echo active + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + cat >"$STUB_DIR/du" <<'DUEOF' +#!/bin/sh +exit 1 +DUEOF + chmod +x "$STUB_DIR/du" + + mkdir -p "$STUB_DIR/var-lib-containerd" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/var-lib-containerd" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone containerd.service' "$STDERR_FILE" + + rm -rf "$STUB_DIR" +} + +@test "socket only fallback fires when systemctl is unavailable" { + STUB_DIR=$(mktemp -d) + SOCK="$STUB_DIR/containerd.sock" + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 not available - skipping socket fallback test" >&2 + rm -rf "$STUB_DIR" + return 0 + fi + python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 \ + COZYSTACK_CONTAINERD_SOCKET="$SOCK" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone containerd.service' "$STDERR_FILE" + + rm -rf "$STUB_DIR" +} From 7c822166d86c0000e6c9949eadcb10dbbcf242f5 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:04:13 +0300 Subject: [PATCH 02/15] feat(hack): add check-host-runtime.sh preflight diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu hosts running the cozystack "generic" variant (k3s or kubeadm) sometimes end up with a standalone containerd.service or docker.service running alongside the embedded k3s runtime. The two runtimes do not fight over sockets — k3s uses /run/k3s/containerd/containerd.sock while the standalone package uses /run/containerd/containerd.sock — so both keep running silently, and the standalone one accumulates unpruned images and build cache in /var/lib/containerd. Over time this fills the root disk, triggers DiskPressure, and sends cozystack-api into an eviction loop. hack/check-host-runtime.sh warns an operator about this before install without blocking it. The script probes systemctl and well-known socket paths, reports disk usage of the standalone data directory when present, prints a hint on how to disable the shadow runtime, and always exits 0. Covered by hack/check-host-runtime.bats (six test cases). Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100755 hack/check-host-runtime.sh diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh new file mode 100755 index 00000000..a3ec6f2d --- /dev/null +++ b/hack/check-host-runtime.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# check-host-runtime.sh — operator preflight warning +# +# Purpose: +# Warn when a standalone containerd.service or docker.service is running on +# the host alongside the embedded k3s runtime. This mismatch is silent on +# day 0 (k3s uses its own containerd at /run/k3s/containerd/containerd.sock +# and /var/lib/rancher/k3s/agent/containerd) but over time the standalone +# runtime accumulates unpruned images and build cache in /var/lib/containerd +# — enough to trigger DiskPressure and crash cozystack-api with eviction +# loops. The script does NOT block install; it only prints a warning. +# +# When to run: +# Before `helm install cozy-installer` on an Ubuntu host prepared with k3s +# or kubeadm (cozystack "generic" variant). Irrelevant on Talos where the +# container runtime lifecycle is fully managed. +# +# Exit code: +# Always 0 (warning, not a blocker). Warnings go to stderr. +# +# Environment variables (test hooks — override default probe paths): +# COZYSTACK_CONTAINERD_SOCKET standalone containerd socket path +# COZYSTACK_DOCKER_SOCKET_PATHS space-separated list of docker socket paths +# COZYSTACK_CONTAINERD_DIR standalone containerd data directory +# COZYSTACK_DOCKER_DIR standalone docker data directory +# COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 pretend systemctl is absent +# ----------------------------------------------------------------------------- +set -euo pipefail + +YELLOW='\033[1;33m' +RESET='\033[0m' + +CONTAINERD_SOCKET=${COZYSTACK_CONTAINERD_SOCKET:-/run/containerd/containerd.sock} +DOCKER_SOCKET_PATHS=${COZYSTACK_DOCKER_SOCKET_PATHS:-/run/docker.sock /var/run/docker.sock} +CONTAINERD_DIR=${COZYSTACK_CONTAINERD_DIR:-/var/lib/containerd} +DOCKER_DIR=${COZYSTACK_DOCKER_DIR:-/var/lib/docker} + +WARNINGS=0 + +warn() { + printf '%bWARNING:%b %s\n' "$YELLOW" "$RESET" "$1" >&2 + WARNINGS=$((WARNINGS + 1)) +} + +detect_systemctl() { + if [ "${COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL:-0}" = "1" ]; then + return 1 + fi + if command -v systemctl >/dev/null 2>&1 && systemctl --version >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +disk_usage() { + local path=$1 + local usage + if [ -d "$path" ]; then + usage=$(du -sh "$path" 2>/dev/null | awk '{print $1}' || true) + if [ -n "${usage:-}" ]; then + printf ' (%s uses %s)' "$path" "$usage" + return 0 + fi + fi + printf '' +} + +service_active() { + local service=$1 + if [ "$HAS_SYSTEMCTL" = "1" ]; then + if systemctl is-active "$service" >/dev/null 2>&1; then + return 0 + fi + fi + return 1 +} + +check_containerd() { + local detail="" + local found=0 + if service_active containerd.service; then + found=1 + fi + if [ -e "$CONTAINERD_SOCKET" ]; then + found=1 + fi + if [ "$found" -eq 1 ]; then + detail=$(disk_usage "$CONTAINERD_DIR") + warn "standalone containerd.service detected alongside k3s embedded runtime${detail}" + fi +} + +check_docker() { + local detail="" + local found=0 + if service_active docker.service; then + found=1 + fi + if [ "$found" -eq 0 ]; then + for sock in $DOCKER_SOCKET_PATHS; do + if [ -e "$sock" ]; then + found=1 + break + fi + done + fi + if [ "$found" -eq 1 ]; then + detail=$(disk_usage "$DOCKER_DIR") + warn "standalone docker.service detected alongside k3s embedded runtime${detail}" + fi +} + +if detect_systemctl; then + HAS_SYSTEMCTL=1 +else + HAS_SYSTEMCTL=0 +fi + +check_containerd +check_docker + +if [ "$WARNINGS" -gt 0 ]; then + printf '%bHINT:%b cozystack runs its own containerd under k3s. To stop the shadow runtime:\n' "$YELLOW" "$RESET" >&2 + printf ' sudo systemctl disable --now docker.service containerd.service\n' >&2 + printf ' sudo rm -rf /var/lib/docker /var/lib/containerd\n' >&2 +fi + +exit 0 From 7b1364e00bcea5df6856e4a27cdd3158e07dd3e5 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:11:40 +0300 Subject: [PATCH 03/15] build(hack): wire bats unit tests into make unit-tests target Add a bats-unit-tests Make target that runs hack/cozytest.sh against hack/check-host-runtime.bats, and make unit-tests depend on it so the existing CI step (make unit-tests in .github/workflows/pull-requests.yaml) exercises the preflight script on every PR. Without this the bats file exists in the tree but is never executed, leaving future regressions undetected. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- Makefile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 59a55bfb..aaffcc81 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: manifests assets unit-tests helm-unit-tests +.PHONY: manifests assets unit-tests helm-unit-tests bats-unit-tests include hack/common-envs.mk @@ -82,11 +82,14 @@ test: make -C packages/core/testing apply make -C packages/core/testing test -unit-tests: helm-unit-tests +unit-tests: helm-unit-tests bats-unit-tests helm-unit-tests: hack/helm-unit-tests.sh +bats-unit-tests: + hack/cozytest.sh hack/check-host-runtime.bats + prepare-env: make -C packages/core/testing apply make -C packages/core/testing prepare-cluster From 87e206de3924b0925c4591dfbc131f704aaeeff2 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:11:50 +0300 Subject: [PATCH 04/15] fix(hack): symmetrize runtime checks and soften HINT text Two small issues caught in review: 1. check_containerd probed the socket unconditionally even when service_active had already set found=1, while check_docker short circuited the same path behind 'if \[ $found -eq 0 \]'. The asymmetry was not user visible but violated least surprise for future maintainers. check_containerd now uses the same gated pattern. 2. The HINT block recommended 'sudo rm -rf /var/lib/docker /var/lib/containerd' as a casual follow up, which could destroy data the operator still needs. Replace that line with a warning telling the operator to inspect and reclaim standalone runtime storage manually rather than deleting it blindly. Also drop a no op 'printf' from disk_usage that served no purpose. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh index a3ec6f2d..97401391 100755 --- a/hack/check-host-runtime.sh +++ b/hack/check-host-runtime.sh @@ -60,10 +60,8 @@ disk_usage() { usage=$(du -sh "$path" 2>/dev/null | awk '{print $1}' || true) if [ -n "${usage:-}" ]; then printf ' (%s uses %s)' "$path" "$usage" - return 0 fi fi - printf '' } service_active() { @@ -82,7 +80,7 @@ check_containerd() { if service_active containerd.service; then found=1 fi - if [ -e "$CONTAINERD_SOCKET" ]; then + if [ "$found" -eq 0 ] && [ -e "$CONTAINERD_SOCKET" ]; then found=1 fi if [ "$found" -eq 1 ]; then @@ -123,7 +121,8 @@ check_docker if [ "$WARNINGS" -gt 0 ]; then printf '%bHINT:%b cozystack runs its own containerd under k3s. To stop the shadow runtime:\n' "$YELLOW" "$RESET" >&2 printf ' sudo systemctl disable --now docker.service containerd.service\n' >&2 - printf ' sudo rm -rf /var/lib/docker /var/lib/containerd\n' >&2 + printf 'Inspect and reclaim standalone runtime storage separately — it may contain container data\n' >&2 + printf 'that the operator still needs; do not delete it blindly.\n' >&2 fi exit 0 From 9a2e889dd9a8369f84115762c1ab4f883cad83fc Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:12:00 +0300 Subject: [PATCH 05/15] test(hack): cover docker socket fallback, HINT block, and single warning guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the preflight test suite: - Add a trap 'rm -rf $STUB_DIR' EXIT in every test immediately after mktemp -d, so temp dirs are cleaned up even when an assertion fails and terminates the test early under set -e. - Assert the HINT block and 'systemctl disable --now' line in the 'both services active' test so a future silent removal or typo in the HINT output is caught by CI. - New test: 'docker socket fallback fires when systemctl is unavailable' — mirrors the existing containerd socket fallback test and exercises the same code path in check_docker. - New test: 'containerd service plus socket still emits exactly one warning' — documents and locks in the intent of the symmetric gated check in check_containerd. Uses grep -c to assert the warning is not double printed when both signals fire. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.bats | 116 +++++++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 19 deletions(-) diff --git a/hack/check-host-runtime.bats b/hack/check-host-runtime.bats index 8ae478c9..73b464ea 100644 --- a/hack/check-host-runtime.bats +++ b/hack/check-host-runtime.bats @@ -12,9 +12,14 @@ # variables to redirect socket/dir probes into the stub tree, so tests do not # need root privileges or a real systemd host. # -# Tests are self-contained — no shared setup/teardown helpers, because -# cozytest.sh's awk parser only recognizes @test blocks and treats a bare `}` -# on its own line as the end of a test function. +# Each test installs a `trap 'rm -rf "$STUB_DIR"' EXIT` immediately after +# creating the stub dir so cleanup runs even when an assertion fails mid-test +# under `set -e`. cozytest.sh runs each @test in its own subshell, so traps +# scope per test and do not leak across tests. +# +# Tests are otherwise self-contained — no shared setup/teardown helpers, +# because cozytest.sh's awk parser only recognizes @test blocks and treats a +# bare `}` on its own line as the end of a test function. # # Run with: hack/cozytest.sh hack/check-host-runtime.bats # (or `bats hack/check-host-runtime.bats` if the bats binary is @@ -23,6 +28,8 @@ @test "clean host with no runtime services exits silently" { STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + cat >"$STUB_DIR/systemctl" <<'STUBEOF' #!/bin/sh if [ "$1" = "--version" ]; then @@ -43,12 +50,12 @@ STUBEOF [ ! -s "$STDERR_FILE" ] [ ! -s "$STUB_DIR/stdout" ] - - rm -rf "$STUB_DIR" } @test "standalone containerd service active prints warning" { STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + cat >"$STUB_DIR/systemctl" <<'STUBEOF' #!/bin/sh if [ "$1" = "--version" ]; then @@ -80,12 +87,12 @@ STUBEOF cat "$STDERR_FILE" >&2 exit 1 fi - - rm -rf "$STUB_DIR" } @test "standalone docker service active prints warning" { STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + cat >"$STUB_DIR/systemctl" <<'STUBEOF' #!/bin/sh if [ "$1" = "--version" ]; then @@ -117,12 +124,12 @@ STUBEOF cat "$STDERR_FILE" >&2 exit 1 fi - - rm -rf "$STUB_DIR" } -@test "both services active prints two warnings" { +@test "both services active prints two warnings and the HINT block" { STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + cat >"$STUB_DIR/systemctl" <<'STUBEOF' #!/bin/sh if [ "$1" = "--version" ]; then @@ -150,12 +157,16 @@ STUBEOF grep -q 'standalone containerd.service' "$STDERR_FILE" grep -q 'standalone docker.service' "$STDERR_FILE" - - rm -rf "$STUB_DIR" + # HINT block must fire whenever warnings exist; otherwise a future silent + # removal of the HINT would go unnoticed. + grep -q 'HINT:' "$STDERR_FILE" + grep -q 'systemctl disable --now' "$STDERR_FILE" } @test "failing du does not suppress the containerd warning" { STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + cat >"$STUB_DIR/systemctl" <<'STUBEOF' #!/bin/sh if [ "$1" = "--version" ]; then @@ -186,18 +197,17 @@ DUEOF bash hack/check-host-runtime.sh 2>"$STDERR_FILE" grep -q 'standalone containerd.service' "$STDERR_FILE" - - rm -rf "$STUB_DIR" } -@test "socket only fallback fires when systemctl is unavailable" { +@test "containerd socket fallback fires when systemctl is unavailable" { STUB_DIR=$(mktemp -d) - SOCK="$STUB_DIR/containerd.sock" + trap 'rm -rf "$STUB_DIR"' EXIT + if ! command -v python3 >/dev/null 2>&1; then echo "python3 not available - skipping socket fallback test" >&2 - rm -rf "$STUB_DIR" return 0 fi + SOCK="$STUB_DIR/containerd.sock" python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" STDERR_FILE="$STUB_DIR/stderr" @@ -209,6 +219,74 @@ DUEOF bash hack/check-host-runtime.sh 2>"$STDERR_FILE" grep -q 'standalone containerd.service' "$STDERR_FILE" - - rm -rf "$STUB_DIR" +} + +@test "docker socket fallback fires when systemctl is unavailable" { + STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 not available - skipping docker socket fallback test" >&2 + return 0 + fi + SOCK="$STUB_DIR/docker.sock" + python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 \ + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$SOCK" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + grep -q 'standalone docker.service' "$STDERR_FILE" + if grep -q 'standalone containerd.service' "$STDERR_FILE"; then + echo "unexpected containerd warning found:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi +} + +@test "containerd service plus socket still emits exactly one warning" { + STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ] && [ "$2" = "containerd.service" ]; then + echo active + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 not available - skipping service+socket test" >&2 + return 0 + fi + SOCK="$STUB_DIR/containerd.sock" + python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + + mkdir -p "$STUB_DIR/var-lib-containerd" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$SOCK" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/var-lib-containerd" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + count=$(grep -c 'standalone containerd.service' "$STDERR_FILE") + if [ "$count" != "1" ]; then + echo "expected exactly one containerd warning, got $count" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi } From e803ce77a7dd90f553b8e075f33519d65722a748 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:21:18 +0300 Subject: [PATCH 06/15] fix(hack): conditional HINT names only detected services When only containerd.service was active, the HINT block still advised the operator to disable both containerd.service and docker.service, which is misleading and potentially dangerous on hosts where docker is legitimately in use. Track per service warnings (CONTAINERD_WARN and DOCKER_WARN) and build the HINT systemctl disable argument from the services that actually fired. Also guard the ANSI color escapes behind an 'is stderr a TTY' check so log files (CI and reviewer captures) do not accumulate raw escape sequences. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh index 97401391..653f3cb3 100755 --- a/hack/check-host-runtime.sh +++ b/hack/check-host-runtime.sh @@ -14,7 +14,8 @@ # When to run: # Before `helm install cozy-installer` on an Ubuntu host prepared with k3s # or kubeadm (cozystack "generic" variant). Irrelevant on Talos where the -# container runtime lifecycle is fully managed. +# container runtime lifecycle is fully managed. Discoverable via +# `make preflight` from the repository root. # # Exit code: # Always 0 (warning, not a blocker). Warnings go to stderr. @@ -28,19 +29,24 @@ # ----------------------------------------------------------------------------- set -euo pipefail -YELLOW='\033[1;33m' -RESET='\033[0m' +if [ -t 2 ]; then + YELLOW=$'\033[1;33m' + RESET=$'\033[0m' +else + YELLOW='' + RESET='' +fi CONTAINERD_SOCKET=${COZYSTACK_CONTAINERD_SOCKET:-/run/containerd/containerd.sock} DOCKER_SOCKET_PATHS=${COZYSTACK_DOCKER_SOCKET_PATHS:-/run/docker.sock /var/run/docker.sock} CONTAINERD_DIR=${COZYSTACK_CONTAINERD_DIR:-/var/lib/containerd} DOCKER_DIR=${COZYSTACK_DOCKER_DIR:-/var/lib/docker} -WARNINGS=0 +CONTAINERD_WARN=0 +DOCKER_WARN=0 warn() { - printf '%bWARNING:%b %s\n' "$YELLOW" "$RESET" "$1" >&2 - WARNINGS=$((WARNINGS + 1)) + printf '%sWARNING:%s %s\n' "$YELLOW" "$RESET" "$1" >&2 } detect_systemctl() { @@ -86,6 +92,7 @@ check_containerd() { if [ "$found" -eq 1 ]; then detail=$(disk_usage "$CONTAINERD_DIR") warn "standalone containerd.service detected alongside k3s embedded runtime${detail}" + CONTAINERD_WARN=1 fi } @@ -106,6 +113,7 @@ check_docker() { if [ "$found" -eq 1 ]; then detail=$(disk_usage "$DOCKER_DIR") warn "standalone docker.service detected alongside k3s embedded runtime${detail}" + DOCKER_WARN=1 fi } @@ -118,9 +126,20 @@ fi check_containerd check_docker -if [ "$WARNINGS" -gt 0 ]; then - printf '%bHINT:%b cozystack runs its own containerd under k3s. To stop the shadow runtime:\n' "$YELLOW" "$RESET" >&2 - printf ' sudo systemctl disable --now docker.service containerd.service\n' >&2 +if [ "$CONTAINERD_WARN" -eq 1 ] || [ "$DOCKER_WARN" -eq 1 ]; then + services="" + if [ "$CONTAINERD_WARN" -eq 1 ]; then + services="containerd.service" + fi + if [ "$DOCKER_WARN" -eq 1 ]; then + if [ -n "$services" ]; then + services="$services docker.service" + else + services="docker.service" + fi + fi + printf '%sHINT:%s cozystack runs its own containerd under k3s. To stop the shadow runtime:\n' "$YELLOW" "$RESET" >&2 + printf ' sudo systemctl disable --now %s\n' "$services" >&2 printf 'Inspect and reclaim standalone runtime storage separately — it may contain container data\n' >&2 printf 'that the operator still needs; do not delete it blindly.\n' >&2 fi From 70f02799b5f6746d989bb6285fb0b9c76f5f7bb0 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:21:30 +0300 Subject: [PATCH 07/15] test(hack): cover conditional HINT, clean no-systemctl, docker symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the bats suite to address a second review pass: - Assert that a containerd only warning produces a HINT that names containerd.service and NOT docker.service, and mirror the check in the docker only test. When both fire, assert the HINT lists both services in a single systemctl disable invocation. - New test: 'clean host without systemctl exits silently' — exercises the COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 path when no standalone sockets exist. Previously the tests only covered the systemd enabled clean host, leaving the non systemd clean path unverified. - New test: 'docker service plus socket still emits exactly one warning' — mirrors the existing containerd service+socket test and locks in the gated check in check_docker. - Replace silent 'return 0' on missing python3 with a visible '# SKIP: python3 unavailable' message on stderr so CI logs make it obvious which tests were skipped on a given runner. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.bats | 85 +++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/hack/check-host-runtime.bats b/hack/check-host-runtime.bats index 73b464ea..98c5603b 100644 --- a/hack/check-host-runtime.bats +++ b/hack/check-host-runtime.bats @@ -87,6 +87,14 @@ STUBEOF cat "$STDERR_FILE" >&2 exit 1 fi + # HINT line must name only the detected service, not advise disabling + # docker.service when only containerd.service is running. + grep -q 'systemctl disable --now containerd.service' "$STDERR_FILE" + if grep -q 'systemctl disable --now.*docker' "$STDERR_FILE"; then + echo "HINT unexpectedly mentions docker:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi } @test "standalone docker service active prints warning" { @@ -124,6 +132,13 @@ STUBEOF cat "$STDERR_FILE" >&2 exit 1 fi + # HINT line must name only the detected service. + grep -q 'systemctl disable --now docker.service' "$STDERR_FILE" + if grep -q 'systemctl disable --now.*containerd' "$STDERR_FILE"; then + echo "HINT unexpectedly mentions containerd:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi } @test "both services active prints two warnings and the HINT block" { @@ -158,9 +173,10 @@ STUBEOF grep -q 'standalone containerd.service' "$STDERR_FILE" grep -q 'standalone docker.service' "$STDERR_FILE" # HINT block must fire whenever warnings exist; otherwise a future silent - # removal of the HINT would go unnoticed. + # removal of the HINT would go unnoticed. When both services fire the HINT + # must list both in a single systemctl disable invocation. grep -q 'HINT:' "$STDERR_FILE" - grep -q 'systemctl disable --now' "$STDERR_FILE" + grep -q 'systemctl disable --now containerd.service docker.service' "$STDERR_FILE" } @test "failing du does not suppress the containerd warning" { @@ -204,7 +220,7 @@ DUEOF trap 'rm -rf "$STUB_DIR"' EXIT if ! command -v python3 >/dev/null 2>&1; then - echo "python3 not available - skipping socket fallback test" >&2 + echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 return 0 fi SOCK="$STUB_DIR/containerd.sock" @@ -226,7 +242,7 @@ DUEOF trap 'rm -rf "$STUB_DIR"' EXIT if ! command -v python3 >/dev/null 2>&1; then - echo "python3 not available - skipping docker socket fallback test" >&2 + echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 return 0 fi SOCK="$STUB_DIR/docker.sock" @@ -248,6 +264,65 @@ DUEOF fi } +@test "clean host without systemctl exits silently" { + STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 \ + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker1.sock $STUB_DIR/missing-docker2.sock" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + bash hack/check-host-runtime.sh >"$STUB_DIR/stdout" 2>"$STDERR_FILE" + + [ ! -s "$STDERR_FILE" ] + [ ! -s "$STUB_DIR/stdout" ] +} + +@test "docker service plus socket still emits exactly one warning" { + STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +if [ "$1" = "is-active" ] && [ "$2" = "docker.service" ]; then + echo active + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + if ! command -v python3 >/dev/null 2>&1; then + echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 + return 0 + fi + SOCK="$STUB_DIR/docker.sock" + python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + + mkdir -p "$STUB_DIR/var-lib-docker" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$SOCK" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/var-lib-docker" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + count=$(grep -c 'standalone docker.service' "$STDERR_FILE") + if [ "$count" != "1" ]; then + echo "expected exactly one docker warning, got $count" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi +} + @test "containerd service plus socket still emits exactly one warning" { STUB_DIR=$(mktemp -d) trap 'rm -rf "$STUB_DIR"' EXIT @@ -267,7 +342,7 @@ STUBEOF chmod +x "$STUB_DIR/systemctl" if ! command -v python3 >/dev/null 2>&1; then - echo "python3 not available - skipping service+socket test" >&2 + echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 return 0 fi SOCK="$STUB_DIR/containerd.sock" From cae08932d3baffa41564f7b50f626e5664cf0c35 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:21:45 +0300 Subject: [PATCH 08/15] build(hack): auto discover unit bats files and expose make preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small Makefile tweaks: 1. bats-unit-tests now iterates over every hack/*.bats file that is not an e2e-*.bats file (wildcard + filter-out), so dropping a new unit bats file into hack/ is enough to get it picked up by CI on the next run. The previous hard coded single file path would have required a Makefile edit for every new unit suite. 2. Add a new 'preflight' target that runs hack/check-host-runtime.sh. This gives the script a discoverable entry point — an operator preparing a generic (k3s/kubeadm) host for cozystack can now run 'make preflight' from the repository root to get the same warning that is described in the script header, without having to know the exact path under hack/. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- Makefile | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index aaffcc81..7f5e715d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: manifests assets unit-tests helm-unit-tests bats-unit-tests +.PHONY: manifests assets unit-tests helm-unit-tests bats-unit-tests preflight include hack/common-envs.mk @@ -87,8 +87,22 @@ unit-tests: helm-unit-tests bats-unit-tests helm-unit-tests: hack/helm-unit-tests.sh +# Discover every hack/*.bats file that is NOT an e2e test and run it +# through cozytest.sh. Drop a new *.bats file in hack/ and it is picked +# up automatically on the next `make unit-tests` run. +BATS_UNIT_FILES := $(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats)) + bats-unit-tests: - hack/cozytest.sh hack/check-host-runtime.bats + @for f in $(BATS_UNIT_FILES); do \ + echo "--- running $$f ---"; \ + hack/cozytest.sh "$$f" || exit 1; \ + done + +# Operator-facing host preflight check. Warns about a standalone +# containerd.service or docker.service running alongside the embedded +# k3s runtime. Safe to run at any time; always exits 0. +preflight: + @hack/check-host-runtime.sh prepare-env: make -C packages/core/testing apply From bce98a432b61698f92607d93b0702a7133c1f762 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:29:30 +0300 Subject: [PATCH 09/15] test(hack): drop python3 dependency and assert sudo in HINT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: 1. The socket fallback tests previously branched on 'command -v python3' and fell through to 'return 0' when it was missing. cozytest.sh has no SKIP concept — 'return 0' is indistinguishable from a real pass, so on a runner without python3 the socket fallback paths were silently unverified. Since the script uses '[ -e "$sock" ]' rather than '[ -S ... ]', a regular file is sufficient to exercise the detection path. Replace the python3 unix-socket creation with 'touch "$SOCK"' so the tests run unconditionally on every runner. 2. Extend the 'both services active' HINT assertion to require the 'sudo ' prefix on the 'systemctl disable --now' line. Without sudo the operator would be instructed to run a privileged command as a non-root user and hit a silent failure; the prefix is as important as the verb itself and must be locked in by a test. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.bats | 38 +++++++++++++++--------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/hack/check-host-runtime.bats b/hack/check-host-runtime.bats index 98c5603b..7506bbab 100644 --- a/hack/check-host-runtime.bats +++ b/hack/check-host-runtime.bats @@ -174,9 +174,11 @@ STUBEOF grep -q 'standalone docker.service' "$STDERR_FILE" # HINT block must fire whenever warnings exist; otherwise a future silent # removal of the HINT would go unnoticed. When both services fire the HINT - # must list both in a single systemctl disable invocation. + # must list both in a single sudo systemctl disable invocation — the sudo + # prefix is as important as the systemctl verb, otherwise the operator + # would be told to run it as a non-root user and quietly fail. grep -q 'HINT:' "$STDERR_FILE" - grep -q 'systemctl disable --now containerd.service docker.service' "$STDERR_FILE" + grep -q 'sudo systemctl disable --now containerd.service docker.service' "$STDERR_FILE" } @test "failing du does not suppress the containerd warning" { @@ -219,12 +221,12 @@ DUEOF STUB_DIR=$(mktemp -d) trap 'rm -rf "$STUB_DIR"' EXIT - if ! command -v python3 >/dev/null 2>&1; then - echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 - return 0 - fi + # The script uses `[ -e "$sock" ]`, not `[ -S ... ]`, so a regular + # file is a valid stand-in for a unix socket in tests. This also + # removes any optional runtime dependency on python3 and makes the + # test unconditional on every CI runner. SOCK="$STUB_DIR/containerd.sock" - python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + touch "$SOCK" STDERR_FILE="$STUB_DIR/stderr" COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 \ @@ -241,12 +243,8 @@ DUEOF STUB_DIR=$(mktemp -d) trap 'rm -rf "$STUB_DIR"' EXIT - if ! command -v python3 >/dev/null 2>&1; then - echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 - return 0 - fi SOCK="$STUB_DIR/docker.sock" - python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + touch "$SOCK" STDERR_FILE="$STUB_DIR/stderr" COZYSTACK_PREFLIGHT_FORCE_NO_SYSTEMCTL=1 \ @@ -298,12 +296,8 @@ exit 1 STUBEOF chmod +x "$STUB_DIR/systemctl" - if ! command -v python3 >/dev/null 2>&1; then - echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 - return 0 - fi SOCK="$STUB_DIR/docker.sock" - python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + touch "$SOCK" mkdir -p "$STUB_DIR/var-lib-docker" @@ -341,12 +335,12 @@ exit 1 STUBEOF chmod +x "$STUB_DIR/systemctl" - if ! command -v python3 >/dev/null 2>&1; then - echo "# SKIP: python3 unavailable - cannot create unix socket" >&2 - return 0 - fi + # The script uses `[ -e "$sock" ]`, not `[ -S ... ]`, so a regular + # file is a valid stand-in for a unix socket in tests. This also + # removes any optional runtime dependency on python3 and makes the + # test unconditional on every CI runner. SOCK="$STUB_DIR/containerd.sock" - python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$SOCK" + touch "$SOCK" mkdir -p "$STUB_DIR/var-lib-containerd" From 2e13648194f1f54716c713b8f3715a92d1dc6ee5 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:29:39 +0300 Subject: [PATCH 10/15] build(hack): fail bats-unit-tests target when no test files are found Without a guard, running make bats-unit-tests in a tree where the hack/*.bats wildcard matches nothing (for example a rename, a shallow clone, or a future reorganization that moves the tests elsewhere) would silently succeed with zero tests run. Add an explicit guard that fails the target with a clear error message when BATS_UNIT_FILES expands to an empty list, so the developer cannot accidentally believe tests passed when no tests executed. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 7f5e715d..8daeabda 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,10 @@ helm-unit-tests: BATS_UNIT_FILES := $(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats)) bats-unit-tests: + @if [ -z "$(BATS_UNIT_FILES)" ]; then \ + echo "ERROR: no hack/*.bats unit test files found"; \ + exit 1; \ + fi @for f in $(BATS_UNIT_FILES); do \ echo "--- running $$f ---"; \ hack/cozytest.sh "$$f" || exit 1; \ From 57b00248798a973b154dc9b6611d1f314fdea64c Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:29:48 +0300 Subject: [PATCH 11/15] docs(hack): document intentional unquoted word split on DOCKER_SOCKET_PATHS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOCKER_SOCKET_PATHS is a space separated list of socket paths (its default is "/run/docker.sock /var/run/docker.sock") that the loop iterates by word splitting. Socket paths never contain whitespace on Linux hosts, so this is the documented and correct idiom — not a shellcheck oversight. Add an inline comment so a future maintainer (or a linter that has not seen the comment) does not break the loop by adding quotes around the expansion. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh index 653f3cb3..2301db0d 100755 --- a/hack/check-host-runtime.sh +++ b/hack/check-host-runtime.sh @@ -103,6 +103,11 @@ check_docker() { found=1 fi if [ "$found" -eq 0 ]; then + # Intentional unquoted expansion: DOCKER_SOCKET_PATHS is a space + # separated list of socket paths (e.g. "/run/docker.sock + # /var/run/docker.sock"). Socket paths never contain spaces in + # practice on Linux hosts, so word splitting is the documented + # mechanism and must not be "fixed" by quoting the expansion. for sock in $DOCKER_SOCKET_PATHS; do if [ -e "$sock" ]; then found=1 From 8d93b5113aecae0a8fb3575c9ae8c3e35e350f72 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:36:44 +0300 Subject: [PATCH 12/15] fix(hack): parse DOCKER_SOCKET_PATHS into an array to suppress glob expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'set -euo pipefail' does not include 'set -f', so the previous 'for sock in $DOCKER_SOCKET_PATHS' loop relied on both word splitting AND unintended glob expansion. If the variable ever contained a literal '*' or '?' — for example a test setting COZYSTACK_DOCKER_SOCKET_PATHS='/run/docker-*.sock' against a tree that happens to have real files matching the pattern — the loop would iterate over directory entries instead of the intended socket paths, producing false-positive docker warnings. Replace the implicit split+glob loop with 'read -ra' into a bash array and iterate 'for sock in "${_socks[@]}"'. This keeps the space-separated input format, disables glob expansion, and removes the fragile word splitting that shellcheck otherwise has to be talked out of. Covered by a new regression test ('docker socket paths with glob chars do not expand') that sets the env var to a literal '*' pattern pointing at real directories; without this fix the test fails with an unexpected docker.service warning. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh index 2301db0d..0ba6d301 100755 --- a/hack/check-host-runtime.sh +++ b/hack/check-host-runtime.sh @@ -103,12 +103,15 @@ check_docker() { found=1 fi if [ "$found" -eq 0 ]; then - # Intentional unquoted expansion: DOCKER_SOCKET_PATHS is a space - # separated list of socket paths (e.g. "/run/docker.sock - # /var/run/docker.sock"). Socket paths never contain spaces in - # practice on Linux hosts, so word splitting is the documented - # mechanism and must not be "fixed" by quoting the expansion. - for sock in $DOCKER_SOCKET_PATHS; do + # DOCKER_SOCKET_PATHS is a space separated list of paths. Parse + # it into an array via `read -ra` so that word splitting is + # explicit AND glob expansion is suppressed — `for sock in + # $DOCKER_SOCKET_PATHS` would both word split and glob, so a + # path containing a literal `*` or `?` could expand into + # directory entries and produce false positives. + local -a _socks + read -ra _socks <<<"$DOCKER_SOCKET_PATHS" + for sock in "${_socks[@]}"; do if [ -e "$sock" ]; then found=1 break From 786ea8a7d8faaba82f9284d15f785bac4c6d768b Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:36:58 +0300 Subject: [PATCH 13/15] test(hack): assert sudo prefix in single-service HINTs, explicit exit code, and glob regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three review findings on the bats suite: - The 'standalone containerd service active' and 'standalone docker service active' tests previously asserted 'systemctl disable --now ' but did not require the 'sudo ' prefix, so the two- service test was the only guard against a silent drop of the prefix. Extend both single-service tests to require the prefix exactly as the 'both services active' test does. - The 'both services active' test now captures the exit code explicitly with 'bash ... || status=$?' and asserts '$status -eq 0'. The script contract ('always exits 0') was previously enforced implicitly via 'set -e', which produces a generic test failure on a regression instead of a contract-specific error. The explicit check locks in the contract and makes regressions readable. - New test: 'docker socket paths with glob chars do not expand' — sets COZYSTACK_DOCKER_SOCKET_PATHS to a literal glob against real directories and asserts no docker warning fires. Locks in the array-based parsing of the path list. - Extend the file header with a 'title syntax constraints' section documenting how cozytest.sh's awk parser treats double quotes and non-alphanumeric characters in @test titles, so future contributors do not stumble on the parser's quirks. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.bats | 64 +++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/hack/check-host-runtime.bats b/hack/check-host-runtime.bats index 7506bbab..b121b31b 100644 --- a/hack/check-host-runtime.bats +++ b/hack/check-host-runtime.bats @@ -21,6 +21,14 @@ # because cozytest.sh's awk parser only recognizes @test blocks and treats a # bare `}` on its own line as the end of a test function. # +# Title syntax constraints (inherited from cozytest.sh's awk parser): +# - Titles must be delimited by ASCII double quotes; embedded literal +# double quotes are NOT escaped and will silently truncate the title. +# - Only alphanumeric characters from the title survive into the shell +# function name (everything else becomes '_'), so titles that differ +# only in punctuation collapse to the same function name. Keep titles +# distinctive in their alphanumeric run. +# # Run with: hack/cozytest.sh hack/check-host-runtime.bats # (or `bats hack/check-host-runtime.bats` if the bats binary is # installed; cozytest.sh is the CI path.) @@ -88,8 +96,10 @@ STUBEOF exit 1 fi # HINT line must name only the detected service, not advise disabling - # docker.service when only containerd.service is running. - grep -q 'systemctl disable --now containerd.service' "$STDERR_FILE" + # docker.service when only containerd.service is running. The sudo + # prefix is also required — without it the command silently no-ops + # for a non-root operator, so the prefix is part of the contract. + grep -q 'sudo systemctl disable --now containerd.service' "$STDERR_FILE" if grep -q 'systemctl disable --now.*docker' "$STDERR_FILE"; then echo "HINT unexpectedly mentions docker:" >&2 cat "$STDERR_FILE" >&2 @@ -132,8 +142,9 @@ STUBEOF cat "$STDERR_FILE" >&2 exit 1 fi - # HINT line must name only the detected service. - grep -q 'systemctl disable --now docker.service' "$STDERR_FILE" + # HINT line must name only the detected service. As in the + # containerd test, the sudo prefix is part of the contract. + grep -q 'sudo systemctl disable --now docker.service' "$STDERR_FILE" if grep -q 'systemctl disable --now.*containerd' "$STDERR_FILE"; then echo "HINT unexpectedly mentions containerd:" >&2 cat "$STDERR_FILE" >&2 @@ -163,13 +174,21 @@ STUBEOF mkdir -p "$STUB_DIR/var-lib-containerd" "$STUB_DIR/var-lib-docker" STDERR_FILE="$STUB_DIR/stderr" + # Capture exit code explicitly: the script contract says exit 0 + # unconditionally (warning, not blocker). `set -e` in the test + # function body would already fail on a nonzero exit, but an + # explicit status check locks in the contract and makes a + # regression show up as "expected 0, got N" rather than as a + # generic test failure. + status=0 COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/missing-docker.sock" \ COZYSTACK_CONTAINERD_DIR="$STUB_DIR/var-lib-containerd" \ COZYSTACK_DOCKER_DIR="$STUB_DIR/var-lib-docker" \ PATH="$STUB_DIR:$PATH" \ - bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" || status=$? + [ "$status" -eq 0 ] grep -q 'standalone containerd.service' "$STDERR_FILE" grep -q 'standalone docker.service' "$STDERR_FILE" # HINT block must fire whenever warnings exist; otherwise a future silent @@ -317,6 +336,41 @@ STUBEOF fi } +@test "docker socket paths with glob chars do not expand" { + STUB_DIR=$(mktemp -d) + trap 'rm -rf "$STUB_DIR"' EXIT + + # Create two directories that a naive `for sock in $PATHS` loop + # would glob-expand and treat as existing "sockets". With the + # array-based parsing the literal path "$STUB_DIR/var-lib-*" does + # not exist and no warning must fire. + mkdir -p "$STUB_DIR/var-lib-docker" "$STUB_DIR/var-lib-containerd" + + cat >"$STUB_DIR/systemctl" <<'STUBEOF' +#!/bin/sh +if [ "$1" = "--version" ]; then + echo "systemd stub" + exit 0 +fi +exit 1 +STUBEOF + chmod +x "$STUB_DIR/systemctl" + + STDERR_FILE="$STUB_DIR/stderr" + COZYSTACK_CONTAINERD_SOCKET="$STUB_DIR/missing-containerd.sock" \ + COZYSTACK_DOCKER_SOCKET_PATHS="$STUB_DIR/var-lib-*" \ + COZYSTACK_CONTAINERD_DIR="$STUB_DIR/missing-containerd-dir" \ + COZYSTACK_DOCKER_DIR="$STUB_DIR/missing-docker-dir" \ + PATH="$STUB_DIR:$PATH" \ + bash hack/check-host-runtime.sh 2>"$STDERR_FILE" + + if grep -q 'standalone docker.service' "$STDERR_FILE"; then + echo "glob pattern expanded — docker warning should not fire:" >&2 + cat "$STDERR_FILE" >&2 + exit 1 + fi +} + @test "containerd service plus socket still emits exactly one warning" { STUB_DIR=$(mktemp -d) trap 'rm -rf "$STUB_DIR"' EXIT From c7c170447208e3537636a074fabafe57e81ba7fa Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 14:37:06 +0300 Subject: [PATCH 14/15] docs(hack): note whitespace caveat on BATS_UNIT_FILES wildcard $(wildcard ...) returns a space-separated list, so a bats file with a literal space in its name would silently split into multiple tokens in the subsequent 'for' loop. All current bats files use hyphen-separated names, so this is not an immediate risk, but the caveat is worth calling out so a future contributor who adopts a different naming convention rewrites the recipe instead of tripping over it. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 8daeabda..0e94c5e6 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,12 @@ helm-unit-tests: # Discover every hack/*.bats file that is NOT an e2e test and run it # through cozytest.sh. Drop a new *.bats file in hack/ and it is picked # up automatically on the next `make unit-tests` run. +# +# Caveat: $(wildcard ...) returns space-separated names, so a filename +# containing a literal space would split into multiple tokens here. All +# current bats files use hyphen-separated names; if the project ever +# introduces whitespace-bearing filenames this recipe must be rewritten +# (e.g. to use `find ... -print0 | xargs -0`). BATS_UNIT_FILES := $(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats)) bats-unit-tests: From 44dd0021e42f24fa3d85b39f5991463f32e58a16 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Sat, 11 Apr 2026 17:07:35 +0300 Subject: [PATCH 15/15] fix(hack): wrap du in 'timeout 5s' to prevent preflight stall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disk_usage helper shells out to 'du -sh $path' for reporting in the warning message. On the exact directories this script is meant to warn about (a /var/lib/containerd accumulating millions of files from months of unpruned builds), traversing the tree with du can take minutes and stall the preflight indefinitely. Wrap the call in 'timeout 5s' so the helper returns quickly even on a pathologically slow filesystem. If the timeout binary is absent (e.g. minimal busybox userland), the pipeline still exits 0 via the existing '|| true' guard and usage stays empty — the warning still prints, just without the size detail — so the change is backward compatible. Addresses an inline review comment from gemini-code-assist on the open PR. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin --- hack/check-host-runtime.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hack/check-host-runtime.sh b/hack/check-host-runtime.sh index 0ba6d301..917453d2 100755 --- a/hack/check-host-runtime.sh +++ b/hack/check-host-runtime.sh @@ -63,7 +63,13 @@ disk_usage() { local path=$1 local usage if [ -d "$path" ]; then - usage=$(du -sh "$path" 2>/dev/null | awk '{print $1}' || true) + # Wrap `du` in `timeout 5s` so a container data directory with + # millions of files (the exact scenario this script exists to + # warn about) cannot stall the preflight indefinitely. If the + # `timeout` binary is absent the pipeline still exits 0 via + # `|| true` and `usage` stays empty; the warning itself is + # still printed, just without the size detail. + usage=$(timeout 5s du -sh "$path" 2>/dev/null | awk '{print $1}' || true) if [ -n "${usage:-}" ]; then printf ' (%s uses %s)' "$path" "$usage" fi