#!/usr/bin/env bash # # Unit tests for install.sh + scripts/lib/{firewall,state,uninstall}.sh — # runs in seconds, no server, no install. # # install.sh is source-able: when sourced it defines every function and then # returns *before* main() (the BASH_SOURCE guard). That lets us exercise the # Python/Node detection, the firewall abstraction, the install-state tracker, # and the canonical uninstall routine against throwaway fixtures and PATH stubs # instead of a real distro and a real /etc. # # Each unit-under-test runs in a subshell that re-enables `set -Eeuo pipefail`, # so an unguarded command silently aborting under set -e is caught here as a # failed assertion rather than on someone's server. # # Run: bash scripts/test/test_install.sh set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" INSTALL_SH="$REPO_DIR/install.sh" LIB_DIR="$REPO_DIR/scripts/lib" # Shared stub factories + the fresh-box fixture builder. # shellcheck source=stubs.sh source "$SCRIPT_DIR/stubs.sh" PASS=0 FAIL=0 SKIP=0 ok() { PASS=$((PASS + 1)); printf ' \033[32m✔\033[0m %s\n' "$1"; } bad() { FAIL=$((FAIL + 1)); printf ' \033[31m✘\033[0m %s\n' "$1"; } skip() { SKIP=$((SKIP + 1)); printf ' \033[33m∼\033[0m %s (skipped)\n' "$1"; } WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT # -------------------------------------------------------------------------- # PATH stubs. # STUB_BIN — node/npm fakes, added on the global PATH only during T3. # PY_STUB — fake python interpreters, applied ONLY inside T1's subshell so # the real python3 stays available for the state.sh test (T6). # Each python stub echoes a fixed "major.minor" when called with `-c`. # -------------------------------------------------------------------------- STUB_BIN="$WORK/bin" PY_STUB="$WORK/pybin" mkdir -p "$STUB_BIN" "$PY_STUB" # A no-op docker stub so the uninstall routine's `command -v docker` succeeds and # its compose-down branch runs deterministically — the CI containers don't have # docker installed, and we're only asserting on dry-run output anyway. make_stub_ok "$STUB_BIN" docker mkpy() { # mkpy { printf '#!/usr/bin/env bash\n' printf 'if [ "${1:-}" = "-c" ]; then printf "%s"; fi\n' "$2" } > "$PY_STUB/$1" chmod +x "$PY_STUB/$1" } # The newer minor-version stubs report an out-of-range 3.15 (rejected); # python3.11 is in range; bare python3 is an old 3.10 (rejected). locate_python # must therefore pick python3.11 — proving it no longer blindly trusts # `python3`. # # The out-of-range stub used to report 3.13, which stopped being out of range # when the gate moved to cover Debian 13 (issue #99), then 3.14, which stopped # being out of range when the gate moved to cover Arch rolling (2026-08 # baseline). Pick a version ABOVE PYTHON_MAX, and move it whenever the ceiling # moves — the point of the stub is "too new", not any particular number. Every # candidate name locate_python probes is shadowed so a real python3.1x on the # host can never win over the fixtures. mkpy python3.14 3.15 mkpy python3.13 3.15 mkpy python3.12 3.15 mkpy python3.11 3.11 mkpy python3 3.10 export PATH="$STUB_BIN:$PATH" # -------------------------------------------------------------------------- # Source install.sh (functions only). Point the install dir at the sandbox. # -------------------------------------------------------------------------- export SERVERKIT_DIR="$WORK/opt/serverkit" # shellcheck disable=SC1090 source "$INSTALL_SH" set +e +u # hand control back to the harness; tests re-arm set -e per subshell printf '\ninstall.sh + lib unit tests\n\n' # -------------------------------------------------------------------------- # T1 — locate_python prefers a supported minor version over a too-old python3. # -------------------------------------------------------------------------- # && chain: set -e is suppressed inside an if-condition substitution (see T16). if ! res="$( set -Eeuo pipefail; PATH="$PY_STUB:$PATH" locate_python >/dev/null 2>&1 && printf '%s' "$PYTHON_BIN" )"; then bad "locate_python aborted under set -Eeuo pipefail" elif [ "$res" = "python3.11" ]; then ok "locate_python picks python3.11 when python3 is 3.10 and python3.12 is out of range" else bad "locate_python chose [$res], expected python3.11" fi # -------------------------------------------------------------------------- # T2 — ver_in_range gate (3.11/3.12/3.13/3.14 accepted, 3.10/3.15 rejected). # # 3.13 MUST be accepted: Debian 13 ships only python3.13, and this assertion # previously required it to be rejected — the suite was actively pinning the # bug that made trixie uninstallable (issue #99). 3.14 MUST be accepted: Arch/ # Manjaro rolling ship only python 3.14 and carry no versioned fallback # packages at all (2026-08 compatibility baseline). # -------------------------------------------------------------------------- if ( set -Eeuo pipefail; ver_in_range 3.11 ) && ( set -Eeuo pipefail; ver_in_range 3.12 ) && \ ( set -Eeuo pipefail; ver_in_range 3.13 ) && ( set -Eeuo pipefail; ver_in_range 3.14 ) && \ ! ( set -Eeuo pipefail; ver_in_range 3.10 ) && ! ( set -Eeuo pipefail; ver_in_range 3.15 ); then ok "ver_in_range accepts 3.11/3.12/3.13/3.14 and rejects 3.10/3.15" else bad "ver_in_range gate is wrong" fi # Debian 13 carries ONLY python3.13, so the ceiling has to include it or the # installer can never satisfy itself from trixie's repos. Guard the constant # directly — the gate above would still pass if PYTHON_MAX regressed to 3.12 # and someone "fixed" the assertion to match. if [ "$PYTHON_MAX" = "3.13" ] || printf '%s\n%s' "3.13" "$PYTHON_MAX" | sort -C -V; then ok "PYTHON_MAX ($PYTHON_MAX) covers Debian 13's python3.13" else bad "PYTHON_MAX is $PYTHON_MAX — Debian 13 ships only python3.13 and would fall to a source build" fi # Same guard for the rolling ceiling: Arch/Manjaro carry ONLY the newest python # (3.14 at the 2026-08 baseline) and no versioned fallback packages, so a # ceiling below it can never be satisfied from their repos. if [ "$PYTHON_MAX" = "3.14" ] || printf '%s\n%s' "3.14" "$PYTHON_MAX" | sort -C -V; then ok "PYTHON_MAX ($PYTHON_MAX) covers Arch rolling's python 3.14" else bad "PYTHON_MAX is $PYTHON_MAX — Arch/Manjaro ship only python 3.14 and would fall to a source build" fi # -------------------------------------------------------------------------- # T3 — node_ready gates on vite 8's Node floor (^20.19.0 || >=22.12.0) AND npm # present. The frontend bundler (rolldown) can't build on older Node — npm # silently drops its native binary — so a source install must fail fast rather # than mid-build; 21.x and 22.11 are also rejected (outside the range). # -------------------------------------------------------------------------- printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/npm"; chmod +x "$STUB_BIN/npm" _mk_node_stub() { printf '#!/usr/bin/env bash\necho v%s\n' "$1" > "$STUB_BIN/node"; chmod +x "$STUB_BIN/node"; } node_gate_ok=1 for v in 20.19.0 20.19.5 22.12.0 22.13.1 24.2.0; do _mk_node_stub "$v" ( set -Eeuo pipefail; node_ready ) || node_gate_ok=0 done for v in 16.20.0 18.19.0 20.17.0 20.18.9 21.7.0 22.11.0; do _mk_node_stub "$v" ( set -Eeuo pipefail; node_ready ) && node_gate_ok=0 done if [ "$node_gate_ok" = 1 ]; then ok "node_ready accepts 20.19+/22.12+ (with npm) and rejects older (incl. v18, 21.x, 22.11)" else bad "node_ready mis-gated a Node version against the vite 8 floor" fi rm -f "$STUB_BIN/node" "$STUB_BIN/npm" # -------------------------------------------------------------------------- # T4 — configure_firewall dry-run prints the exact firewall commands and # records nothing (FW_DRY_RUN suppresses state writes). # -------------------------------------------------------------------------- state_file="$WORK/state-t4.json" if ! out="$( set -Eeuo pipefail export SERVERKIT_STATE_FILE="$state_file" FW_DRY_RUN=1 FIREWALL_BACKEND=firewalld configure_firewall 2>&1 )"; then bad "configure_firewall dry-run returned non-zero: [$out]" elif printf '%s' "$out" | grep -q 'firewall-cmd --permanent --add-port=80/tcp' && \ printf '%s' "$out" | grep -q 'firewall-cmd --permanent --add-port=443/tcp'; then ok "configure_firewall dry-run prints the expected firewalld commands" else bad "configure_firewall dry-run did not print the expected commands" fi if [ ! -f "$state_file" ]; then ok "configure_firewall dry-run writes no install-state.json" else bad "configure_firewall dry-run unexpectedly wrote state" fi # -------------------------------------------------------------------------- # T5 — firewall_detect honors the FIREWALL_BACKEND override. # -------------------------------------------------------------------------- if ! res="$( set -Eeuo pipefail; source "$LIB_DIR/firewall.sh" \ && FIREWALL_BACKEND=ufw firewall_detect )"; then bad "firewall_detect aborted under set -Eeuo pipefail" elif [ "$res" = "ufw" ]; then ok "firewall_detect honors FIREWALL_BACKEND override" else bad "firewall_detect ignored the override (got [$res])" fi # -------------------------------------------------------------------------- # T6 — state.sh roundtrip: set/get scalar, append dedup, list. # -------------------------------------------------------------------------- if command -v python3 >/dev/null 2>&1; then t6_rc=0 res="$( # && chain: this capture sits on the left of `||`, where bash # suppresses set -e even inside the substitution (see T16). # tr -d '\r' normalizes the CRLF that Python's text-mode stdout emits on # Windows; on Linux there is nothing to strip. set -Eeuo pipefail export SERVERKIT_STATE_FILE="$WORK/state-t6.json" source "$LIB_DIR/state.sh" \ && state_set firewall_backend firewalld \ && state_append firewall_ports 80/tcp \ && state_append firewall_ports 443/tcp \ && state_append firewall_ports 80/tcp `# duplicate — must be ignored` \ && printf '%s|%s' "$(state_get firewall_backend | tr -d '\r')" \ "$(state_list firewall_ports | tr -d '\r' | tr '\n' ',')" )" || t6_rc=$? if [ "$t6_rc" -eq 0 ] && [ "$res" = "firewalld|80/tcp,443/tcp," ]; then ok "state.sh set/get/append(dedup)/list roundtrip" else bad "state.sh roundtrip wrong: rc=$t6_rc got [$res]" fi else skip "state.sh roundtrip — python3 unavailable here" fi # -------------------------------------------------------------------------- # T7 — the canonical uninstall routine: default preserves data, --purge # removes it, --keep-data preserves the config dir. All in dry-run. # -------------------------------------------------------------------------- inst="$WORK/opt/serverkit" mkdir -p "$inst/scripts/lib" cp "$LIB_DIR/firewall.sh" "$LIB_DIR/state.sh" "$inst/scripts/lib/" touch "$inst/docker-compose.yml" uninstall_out() { # uninstall_out ( # && chain: called from if-conditions, where set -e is suppressed # all the way down (see T16). set -Eeuo pipefail source "$LIB_DIR/uninstall.sh" \ && export SERVERKIT_DIR="$inst" SERVERKIT_UNINSTALL_DRY_RUN=1 \ && eval "$1" \ && serverkit_uninstall_core 2>&1 ) } if ! def_out="$(uninstall_out '')"; then bad "uninstall default (dry-run) returned non-zero" elif printf '%s' "$def_out" | grep -q 'down --remove-orphans' && \ ! printf '%s' "$def_out" | grep -q 'down -v' && \ ! printf '%s' "$def_out" | grep -q 'rm -rf /var/lib/serverkit'; then ok "uninstall default: compose down WITHOUT -v and keeps /var/lib/serverkit" else bad "uninstall default behaved wrong (deleted data or used -v)" fi if ! purge_out="$(uninstall_out 'export SERVERKIT_PURGE=1')"; then bad "uninstall --purge (dry-run) returned non-zero" elif printf '%s' "$purge_out" | grep -q 'down -v' && \ printf '%s' "$purge_out" | grep -q 'rm -rf /var/lib/serverkit'; then ok "uninstall --purge: compose down -v and removes data dirs" else bad "uninstall --purge did not remove volumes/data" fi if ! keep_out="$(uninstall_out 'export SERVERKIT_KEEP_DATA=1')"; then bad "uninstall --keep-data (dry-run) returned non-zero" elif printf '%s' "$keep_out" | grep -q 'Preserving data' && \ ! printf '%s' "$keep_out" | grep -q 'rm -rf /etc/serverkit'; then ok "uninstall --keep-data: preserves /etc/serverkit" else bad "uninstall --keep-data removed the config dir" fi # -------------------------------------------------------------------------- # T8 — os_family_from: ID mapping + ID_LIKE fallback (incl. rhel-before-fedora). # -------------------------------------------------------------------------- fam_ok=1 check_fam() { # check_fam local got; got="$(os_family_from "$1" "$2")" [ "$got" = "$3" ] || { bad "os_family_from('$1','$2') -> [$got], expected $3"; fam_ok=0; } } check_fam ubuntu "" debian check_fam debian "" debian check_fam rocky "" rhel check_fam fedora "" fedora check_fam opensuse-leap "" suse check_fam arch "" arch check_fam alpine "" alpine check_fam gentoo "" gentoo check_fam funtoo "" gentoo check_fam mydistro "debian" debian # unknown ID, debian-like check_fam clone "rhel centos fedora" rhel # RHEL clone: rhel wins over fedora check_fam spin "fedora" fedora # pure fedora spin check_fam suselike "suse opensuse" suse check_fam wat "" unknown [ "$fam_ok" = "1" ] && ok "os_family_from maps known IDs and falls back to ID_LIKE (rhel before fedora)" # -------------------------------------------------------------------------- # T9 — render_service_unit honors a custom SERVERKIT_DIR (no @PLACEHOLDERS@). # -------------------------------------------------------------------------- inst2="$WORK/custom/srv" mkdir -p "$inst2/templates" cp "$REPO_DIR/templates/serverkit-backend.service.in" "$inst2/templates/" unit_out="$WORK/rendered.service" t9_rc=0 ( set -Eeuo pipefail INSTALL_DIR="$inst2"; VENV_DIR="$inst2/venv"; LOG_DIR="/var/log/serverkit" render_service_unit "$unit_out" ) || t9_rc=$? if [ "$t9_rc" -eq 0 ] && grep -q "WorkingDirectory=$inst2/backend" "$unit_out" && \ grep -q "$inst2/venv/bin/gunicorn" "$unit_out" && \ ! grep -q '@SERVERKIT_DIR@\|@SERVERKIT_VENV_DIR@\|@PORT@' "$unit_out"; then ok "render_service_unit substitutes a custom SERVERKIT_DIR and leaves no placeholders" else bad "render_service_unit left placeholders or used the wrong paths" fi # -------------------------------------------------------------------------- # T10 — harden_global_tls: conf.d snippet when safe, in-place edit otherwise. # -------------------------------------------------------------------------- # (A) RHEL-style: no ssl_protocols, has conf.d include → reversible snippet. ndA="$WORK/nginxA"; mkdir -p "$ndA/conf.d" printf 'http {\n include /etc/nginx/conf.d/*.conf;\n}\n' > "$ndA/nginx.conf" tlsA_rc=0 ( set -Eeuo pipefail; SERVERKIT_NGINX_DIR="$ndA" harden_global_tls ) || tlsA_rc=$? if [ "$tlsA_rc" -eq 0 ] && [ -f "$ndA/conf.d/serverkit-tls.conf" ] && \ grep -q 'TLSv1.2 TLSv1.3' "$ndA/conf.d/serverkit-tls.conf" && \ ! grep -q 'ssl_protocols' "$ndA/nginx.conf"; then ok "harden_global_tls drops a reversible conf.d snippet when nginx.conf has none" else bad "harden_global_tls should have written a conf.d snippet (RHEL-style)" fi # (B) Debian-style: ssl_protocols already present → edit in place, NO snippet # (a second declaration would be a 'duplicate ssl_protocols' error). ndB="$WORK/nginxB"; mkdir -p "$ndB/conf.d" printf 'http {\n ssl_protocols TLSv1.1 TLSv1.2;\n include /etc/nginx/conf.d/*.conf;\n}\n' > "$ndB/nginx.conf" tlsB_rc=0 ( set -Eeuo pipefail; SERVERKIT_NGINX_DIR="$ndB" harden_global_tls ) || tlsB_rc=$? if [ "$tlsB_rc" -eq 0 ] && [ ! -f "$ndB/conf.d/serverkit-tls.conf" ] && \ grep -q 'ssl_protocols TLSv1.2 TLSv1.3;' "$ndB/nginx.conf"; then ok "harden_global_tls edits nginx.conf in place (no duplicate) when ssl_protocols exists" else bad "harden_global_tls should have edited in place (Debian-style)" fi # -------------------------------------------------------------------------- # T11 — choose_pkg_manager halts cleanly when no package manager is present # (empty PATH hides apt/dnf/yum/zypper/pacman/apk). No /etc writes happen # because the apt branch is never reached. # -------------------------------------------------------------------------- if ( set -Eeuo pipefail; PATH=""; choose_pkg_manager ) >/dev/null 2>&1; then bad "choose_pkg_manager should fail when no package manager exists" else ok "choose_pkg_manager halts cleanly when no package manager is found" fi # -------------------------------------------------------------------------- # T12 — docker-compose.yml shape. # # Two separate contracts live in this one file, and both have already been # broken once: # # 1. No `backend:` service. On a HOST install the backend manages the host # (creates /var/serverkit/apps/*, drives host Docker, reloads nginx), # which a container cannot do — a backend container fails app creation # with `Permission denied: /var/serverkit/apps/...` and has no host # docker.sock. This broke WordPress/app creation once already. # 2. Plain `docker compose up -d` must start something. Every service was # once behind the `legacy-frontend` profile, so the default command # started NOTHING while the README still told people to run it — issue # #96, where the reporter ran the profile by hand and got an nginx # container serving an empty dist directory (403). # -------------------------------------------------------------------------- compose="$REPO_DIR/docker-compose.yml" # Service keys inside the `services:` block that carry no `profiles:` — i.e. # exactly what a plain `docker compose up -d` would start. Literal spaces # rather than {2}/{4} intervals so this works on any awk. compose_default_services="$(awk ' /^services:/ { in_services = 1; next } /^[a-zA-Z]/ { in_services = 0 } !in_services { next } /^ [a-zA-Z0-9_-]+:[ ]*$/ { svc = $1; sub(/:$/, "", svc); order[++n] = svc; cur = svc; next } /^ profiles:/ { if (cur != "") profiled[cur] = 1 } END { for (i = 1; i <= n; i++) if (!profiled[order[i]]) print order[i] } ' "$compose" | sort | tr '\n' ' ')" if grep -Eq '^[[:space:]]{2}backend:' "$compose"; then bad "docker-compose.yml defines a 'backend' service — the backend must run on the host, not in a container" elif ! grep -Eq '^[[:space:]]{2}frontend:' "$compose"; then bad "docker-compose.yml is missing the legacy 'frontend' service" elif ! grep -q 'backend:host-gateway' "$compose"; then bad "docker-compose.yml frontend is missing the 'backend:host-gateway' alias to reach the host backend" elif ! awk '/^ frontend:/{f=1} f&&/^ profiles:/{print;exit}' "$compose" | grep -q 'legacy-frontend'; then bad "the legacy frontend service is no longer behind the 'legacy-frontend' profile" else ok "docker-compose.yml has no backend service and keeps the legacy frontend profiled" fi # `docker compose up -d` must resolve to at least one service. Anything without # a `profiles:` key starts by default. if [ -z "${compose_default_services// /}" ]; then bad "every docker-compose.yml service is behind a profile — 'docker compose up -d' would start NOTHING (issue #96)" elif ! grep -Eq '^[[:space:]]{2}serverkit:' "$compose"; then bad "docker-compose.yml no longer defines the all-in-one 'serverkit' service" else ok "'docker compose up -d' starts the all-in-one panel [$compose_default_services]" fi # The all-in-one image writes its SQLite database to /app/instance. The # Dockerfile must create that directory and chown it to the non-root runtime # user, or the container dies on boot with "unable to open database file" — # and a named volume mounted there inherits root ownership (issue #96). dockerfile="$REPO_DIR/Dockerfile" if ! grep -q '/app/instance' "$dockerfile"; then bad "Dockerfile never creates /app/instance — the container cannot open its database" elif ! grep -Eq 'mkdir -p .*/app/instance' "$dockerfile"; then bad "Dockerfile mentions /app/instance but does not mkdir it" else ok "Dockerfile creates /app/instance for the database volume" fi # -------------------------------------------------------------------------- # T13 — I1: the default no-domain install must survive install_nginx_config_ # for_mode. `[ -n "$PANEL_DOMAIN" ] && printf ...` as the function's LAST # statement returned 1 on an empty domain and set -e killed every curl-pipe # install at the nginx phase (shipped broken since v1.6.25). # -------------------------------------------------------------------------- t="$WORK/t13"; mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/cfg" printf 'server { listen 80; }\n' > "$t/nginx/sites-available/serverkit-insecure.conf" printf 'ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;\n' \ > "$t/nginx/sites-available/serverkit.conf" if ( set -Eeuo pipefail SERVERKIT_NGINX_DIR="$t/nginx" SERVERKIT_CONFIG_DIR="$t/cfg" \ PANEL_DOMAIN="" SSL_MODE=insecure install_nginx_config_for_mode ) >/dev/null 2>&1; then if [ "$(cat "$t/cfg/ssl-mode")" = "insecure" ] && [ ! -f "$t/cfg/panel-domain" ]; then ok "install_nginx_config_for_mode survives an empty PANEL_DOMAIN (the v1.6.25 no-domain abort)" else bad "install_nginx_config_for_mode wrote the wrong mode/domain state for a no-domain install" fi else bad "install_nginx_config_for_mode DIED with an empty PANEL_DOMAIN under set -e (I1 regression)" fi if ( set -Eeuo pipefail SERVERKIT_NGINX_DIR="$t/nginx" SERVERKIT_CONFIG_DIR="$t/cfg" \ PANEL_DOMAIN="panel.example.com" SSL_MODE=secure install_nginx_config_for_mode ) >/dev/null 2>&1 \ && grep -q 'live/panel.example.com/' "$t/nginx/sites-available/serverkit.conf" \ && [ "$(cat "$t/cfg/panel-domain")" = "panel.example.com" ]; then ok "install_nginx_config_for_mode (secure) applies the cert path and persists the domain" else bad "install_nginx_config_for_mode (secure) did not apply the cert path / persist the domain" fi # -------------------------------------------------------------------------- # T14 — I2: plain Enter at the interactive domain prompt must not abort. # Same end-of-function `[ -n ] && ...` shape as I1. SERVERKIT_FORCE_PROMPT=1 # lets the test drive the prompt from a pipe instead of a tty. # -------------------------------------------------------------------------- if printf '\n' | ( set -Eeuo pipefail PANEL_DOMAIN="" SERVERKIT_FORCE_PROMPT=1 prompt_for_domain ) >/dev/null 2>&1; then ok "prompt_for_domain survives a blank answer (plain Enter, the no-domain default)" else bad "prompt_for_domain DIED on a blank answer under set -e (I2 regression)" fi if ! res="$(printf 'panel.example.com\n' | ( set -Eeuo pipefail PANEL_DOMAIN=""; PANEL_PORT=""; SERVERKIT_FORCE_PROMPT=1 prompt_for_domain >/dev/null 2>&1 \ && printf '%s|%s' "$PANEL_DOMAIN" "$PANEL_PORT" ))"; then bad "prompt_for_domain aborted on a typed domain under set -Eeuo pipefail" elif [ "$res" = "panel.example.com|80" ]; then ok "prompt_for_domain accepts a typed domain and sets the port" else bad "prompt_for_domain returned [$res], expected panel.example.com|80" fi # -------------------------------------------------------------------------- # T15 — I4 (detection half): should_default_to_release must see an EXISTING # install. The old check probed backend/src (never exists — the tree is # backend/app), so every re-run picked release mode and fetch_release # rm -rf'd the live install, .env + SQLite DB included. # -------------------------------------------------------------------------- t="$WORK/t15" mkdir -p "$t/fresh" "$t/existing/backend/app" "$t/envonly" printf 'SECRET_KEY=x\n' > "$t/envonly/.env" det_ok=1 ( set -Eeuo pipefail; INSTALL_DIR="$t/fresh"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION="" should_default_to_release ) || { bad "fresh box should default to a release install"; det_ok=0; } ( set -Eeuo pipefail; INSTALL_DIR="$t/existing"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION="" should_default_to_release ) && { bad "existing backend/app tree must NOT default to release (data loss)"; det_ok=0; } ( set -Eeuo pipefail; INSTALL_DIR="$t/envonly"; BUILD_FROM_SOURCE=0; SERVERKIT_VERSION="" should_default_to_release ) && { bad "existing .env must NOT default to release (data loss)"; det_ok=0; } ( set -Eeuo pipefail; INSTALL_DIR="$t/fresh"; BUILD_FROM_SOURCE=1; SERVERKIT_VERSION="" should_default_to_release ) && { bad "BUILD_FROM_SOURCE=1 must force the source path"; det_ok=0; } [ "$det_ok" = "1" ] && ok "should_default_to_release detects an existing install (backend/app or .env)" # -------------------------------------------------------------------------- # T16 — I4 (preservation half): live state survives a slot rewrite # byte-identical. First the helpers in isolation, then the real fetch_release # offline-tarball path over a symlinked blue/green layout (Linux CI; skipped # where symlinks are unsupported, same convention as test_update.sh T4). # -------------------------------------------------------------------------- t="$WORK/t16"; mkdir -p "$t/slot/backend/instance" printf 'SECRET_KEY=sentinel-abc123\nSERVERKIT_ENCRYPTION_KEY=k\n' > "$t/slot/.env" printf 'SQLITE-SENTINEL-BYTES\n' > "$t/slot/backend/instance/serverkit.db" cp "$t/slot/.env" "$t/env.orig" cp "$t/slot/backend/instance/serverkit.db" "$t/db.orig" if ( # One && chain, not sequential statements: bash suppresses set -e inside # an if-condition subshell, so only an explicit chain makes every # assertion gate the result (a cmp-less EL9 CI image proved this — # the sibling t16r test passed vacuously while cmp exited 127). set -Eeuo pipefail stash="$(stash_live_state "$t/slot")" \ && [ -n "$stash" ] \ && rm -rf "$t/slot" \ && mkdir -p "$t/slot/backend" `# fresh tree: no .env, no instance/` \ && restore_live_state "$stash" "$t/slot" \ && files_identical "$t/slot/.env" "$t/env.orig" \ && files_identical "$t/slot/backend/instance/serverkit.db" "$t/db.orig" ) >/dev/null 2>&1; then ok "stash/restore_live_state carries .env + instance DB byte-identical across a rewrite" else bad "stash/restore_live_state lost or altered the live state" fi t="$WORK/t16r"; slotA="$t/sk-a"; slotB="$t/sk-b"; inst="$t/sk" mkdir -p "$slotA/backend/instance" printf 'SECRET_KEY=keep-me\n' > "$slotA/.env" printf 'DB-SENTINEL\n' > "$slotA/backend/instance/serverkit.db" cp "$slotA/.env" "$t/env.orig"; cp "$slotA/backend/instance/serverkit.db" "$t/db.orig" ln -sfn "$slotA" "$inst" 2>/dev/null || true if [ ! -L "$inst" ]; then skip "fetch_release live-state carry — symlinks unsupported here (runs on Linux CI)" elif ! command -v tar >/dev/null 2>&1; then # openSUSE Leap minimal images ship without tar; neither the fixture nor # fetch_release itself can run there. Proven on the other 6 distros. skip "fetch_release live-state carry — tar unavailable here" else stage="$t/stage"; mkdir -p "$stage/serverkit/backend/app" "$stage/serverkit/scripts" printf '#!/bin/sh\n' > "$stage/serverkit/serverkit" printf '9.9.9\n' > "$stage/serverkit/VERSION" tar czf "$t/rel.tar.gz" -C "$stage" serverkit if ( set -Eeuo pipefail INSTALL_DIR="$inst"; DIR_A="$slotA"; DIR_B="$slotB"; FIRST_SLOT="$slotA" SERVERKIT_OFFLINE_TARBALL="$t/rel.tar.gz" fetch_release \ && files_identical "$slotA/.env" "$t/env.orig" \ && files_identical "$slotA/backend/instance/serverkit.db" "$t/db.orig" \ && [ -d "$slotA/backend/app" ] ) >/dev/null 2>&1; then ok "fetch_release re-run preserves .env + database byte-identical (the data-loss bug)" else bad "fetch_release re-run destroyed or altered .env / the database (I4 regression)" fi fi # -------------------------------------------------------------------------- # T17 — I20: snapshot_existing must refresh a stale .backup instead of # silently keeping a months-old tree (which revert_install would then # restore over a much newer install). # -------------------------------------------------------------------------- t="$WORK/t17"; mkdir -p "$t/inst" printf 'v1\n' > "$t/inst/marker" snap_rc=0 ( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; snapshot_existing ) >/dev/null 2>&1 || snap_rc=$? if [ "$snap_rc" -eq 0 ] && [ -f "$t/inst.backup/marker" ] && grep -q v1 "$t/inst.backup/marker"; then ok "snapshot_existing creates the first backup" else bad "snapshot_existing rc=$snap_rc or did not create a backup" fi printf 'v2\n' > "$t/inst/marker" snap_rc=0 ( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; snapshot_existing ) >/dev/null 2>&1 || snap_rc=$? if [ "$snap_rc" -eq 0 ] && grep -q v2 "$t/inst.backup/marker" 2>/dev/null && [ ! -d "$t/inst.backup.new" ]; then ok "snapshot_existing refreshes a stale .backup on re-run (no months-old rollback source)" else bad "snapshot_existing rc=$snap_rc, kept the stale backup (I20 regression) or left .backup.new behind" fi # -------------------------------------------------------------------------- # T18 — I3: the release-venv install must not delete its own copy source. In # the default layout $VENV_DIR resolves to $FIRST_SLOT/venv, so the old # rm -rf + cp deleted the venv then failed to copy it. Same-path → keep in # place; distinct path → still copies. # -------------------------------------------------------------------------- t="$WORK/t18"; mkdir -p "$t/slot/venv/bin" "$t/elsewhere" : > "$t/slot/venv/bin/activate" printf '#!/bin/sh\n' > "$t/slot/venv/bin/python"; chmod +x "$t/slot/venv/bin/python" if ( set -Eeuo pipefail INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/slot/venv" locate_python() { PYTHON_BIN=python3.14; return 0; } ensure_python_build_deps() { exit 1; } provision_python build_virtualenv \ && [ -f "$t/slot/venv/bin/activate" ] ) >/dev/null 2>&1; then ok "build_virtualenv keeps the venv when source and destination are the same directory" else bad "build_virtualenv deleted its own venv on the same-path layout (I3 regression)" fi if ( set -Eeuo pipefail INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/elsewhere/venv" build_virtualenv \ && [ -f "$t/elsewhere/venv/bin/activate" ] \ && [ -f "$t/slot/venv/bin/activate" ] ) >/dev/null 2>&1; then ok "build_virtualenv still copies the release venv to a distinct VENV_DIR" else bad "build_virtualenv failed to copy the release venv to a custom VENV_DIR" fi ln -sfn "$t/slot" "$t/link" 2>/dev/null || true if [ ! -L "$t/link" ]; then skip "build_virtualenv symlinked default layout — symlinks unsupported here (runs on Linux CI)" else if ( set -Eeuo pipefail INSTALL_FROM_RELEASE=1; FIRST_SLOT="$t/slot"; VENV_DIR="$t/link/venv" build_virtualenv \ && [ -x "$t/slot/venv/bin/python" ] ) >/dev/null 2>&1; then ok "build_virtualenv survives the default symlinked layout (/opt/serverkit → slot)" else bad "build_virtualenv broke on the symlinked layout (I3 regression)" fi fi # -------------------------------------------------------------------------- # T19 — I6/I12: pkg_add promises warn-and-continue, so a failing package # manager must produce a warning and exit 0 — the old body died on the # out=$(...) assignment under set -e, printing NOTHING. refresh_pkg_index is # best-effort too, and its yum arm must not use the dnf-only --refresh flag. # -------------------------------------------------------------------------- t="$WORK/t19"; mkdir -p "$t/bin" printf '#!/usr/bin/env bash\necho "E: Unable to locate package"\nexit 100\n' > "$t/bin/apt-get" chmod +x "$t/bin/apt-get" out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt pkg_add nosuchpkg 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Could not install: nosuchpkg'; then ok "pkg_add warns and returns 0 when the package manager fails (errexit-immune body)" else bad "pkg_add rc=$rc (expected 0) or printed no warning: [$out]" fi if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt refresh_pkg_index ) >/dev/null 2>&1; then ok "refresh_pkg_index is best-effort (a failed index refresh never aborts)" else bad "refresh_pkg_index propagated a failure under set -e (I6 regression)" fi if ! grep -qE '^[^#]*yum makecache --refresh' "$INSTALL_SH"; then ok "refresh_pkg_index yum arm avoids the dnf-only --refresh flag (I12)" else bad "the yum arm uses 'makecache --refresh' again — that flag is dnf-only (I12 regression)" fi # -------------------------------------------------------------------------- # T20 — I5: the RHEL-9 upfront openssh+openssl paired upgrade (drops sshd # 'OpenSSL version mismatch' on Rocky 9) must run the exact paired # transaction, survive a failing dnf, and no-op on other families. # -------------------------------------------------------------------------- t="$WORK/t20"; mkdir -p "$t/bin" DNF_LOG="$t/dnf.log"; : > "$DNF_LOG" cat > "$t/bin/dnf" <> "$DNF_LOG" exit 1 EOF chmod +x "$t/bin/dnf" if ( set -Eeuo pipefail; PATH="$t/bin:$PATH"; OS_FAMILY=rhel upgrade_rhel_crypto_stack ) >/dev/null 2>&1 \ && grep -q 'upgrade -y openssh openssh-server openssh-clients openssl openssl-libs openssl-devel' "$DNF_LOG"; then ok "upgrade_rhel_crypto_stack upgrades openssh+openssl in ONE transaction and survives a failing dnf" else bad "upgrade_rhel_crypto_stack missing/wrong dnf transaction or aborted (I5 regression)" fi : > "$DNF_LOG" rhel_rc=0 ( set -Eeuo pipefail; PATH="$t/bin:$PATH"; OS_FAMILY=debian upgrade_rhel_crypto_stack ) >/dev/null 2>&1 || rhel_rc=$? if [ "$rhel_rc" -eq 0 ] && [ ! -s "$DNF_LOG" ]; then ok "upgrade_rhel_crypto_stack is a no-op outside the RHEL family" else bad "upgrade_rhel_crypto_stack rc=$rhel_rc or ran dnf on a non-RHEL family" fi # -------------------------------------------------------------------------- # T21 — I7: sync_source needs git but nothing installed it. Without git and # with the on-demand install failing, sync_source must halt LOUDLY (clear # message) before ever attempting the clone. Curated PATH: a failing apt-get # plus a real tail; no git anywhere. # -------------------------------------------------------------------------- t="$WORK/t21"; mkdir -p "$t/bin" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/apt-get"; chmod +x "$t/bin/apt-get" printf '#!/usr/bin/env bash\nexec %s "$@"\n' "$(command -v tail)" > "$t/bin/tail"; chmod +x "$t/bin/tail" out="$( set -Eeuo pipefail; PATH="$t/bin"; PKG_MGR=apt sync_source 2>&1 )" rc=$? if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'git is required'; then ok "sync_source installs git on demand and halts clearly when that fails (never a cryptic clone error)" else bad "sync_source rc=$rc without the 'git is required' halt: [$out]" fi # -------------------------------------------------------------------------- # T22 — I8: locate_python must reject an interpreter whose venv module is # broken (Debian/Ubuntu minimal without pythonX.Y-venv → 'ensurepip is not # available' much later), and on Debian-family it must first try to install # the matching pythonX.Y-venv package. # -------------------------------------------------------------------------- t="$WORK/t22"; PYX="$t/py"; mkdir -p "$PYX" "$t/bin" mkvless() { # mkvless — version OK, `-m venv` broken { printf '#!/usr/bin/env bash\n' printf 'if [ "${1:-}" = "-c" ]; then printf "%s"; exit 0; fi\n' "$2" printf 'if [ "${1:-}" = "-m" ]; then exit 1; fi\n' printf 'exit 0\n' } > "$PYX/$1" chmod +x "$PYX/$1" } # Every candidate name locate_python probes must be shadowed here — a real # python3.14 (Manjaro) or python3.13 (Gentoo stage3) earlier in PATH would # otherwise win over the fixtures and turn this into a host-dependent test. mkvless python3.14 3.15 # out of range — rejected before the venv probe mkvless python3.13 3.15 # out of range — rejected before the venv probe mkvless python3.12 3.12 # in range but venv-less — must be rejected mkvless python3.11 3.11 # in range but venv-less — must be rejected mkvless python3 3.10 # too old if ( set -Eeuo pipefail; PATH="$PYX:$PATH"; locate_python ) >/dev/null 2>&1; then bad "locate_python accepted a venv-less interpreter (I8 regression)" else ok "locate_python rejects an interpreter whose '-m venv' is broken" fi APT_LOG="$t/apt.log"; : > "$APT_LOG" cat > "$t/bin/apt-get" <> "$APT_LOG" exit 1 EOF chmod +x "$t/bin/apt-get" ( set -Eeuo pipefail; PATH="$t/bin:$PYX:$PATH"; OS_FAMILY=debian PKG_MGR=apt locate_python ) >/dev/null 2>&1 # Match the PACKAGE, not the exact argv: pkg_add also passes # -o Dpkg::Options::=… now, and an assertion pinned to `install -y ` # breaks on any future flag without the behaviour under test changing. if grep -qE 'install .*python3\.11-venv' "$APT_LOG"; then ok "locate_python (Debian family) tries to install the matching pythonX.Y-venv package" else bad "locate_python never attempted the python3.11-venv fallback; apt saw: $(tr '\n' ';' < "$APT_LOG")" fi # -------------------------------------------------------------------------- # T23 — I9: distro-repo Docker (docker.io) has no Docker-repo # 'docker-compose-plugin'; ensure_compose_plugin must fall back to Ubuntu's # 'docker-compose-v2' and, when neither lands, warn and continue instead of # aborting the install. # -------------------------------------------------------------------------- t="$WORK/t23"; mkdir -p "$t/bin" APT_LOG="$t/apt.log"; : > "$APT_LOG" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/docker"; chmod +x "$t/bin/docker" cat > "$t/bin/apt-get" <> "$APT_LOG" exit 100 EOF chmod +x "$t/bin/apt-get" out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; PKG_MGR=apt ensure_compose_plugin 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && grep -qE 'install .*docker-compose-plugin' "$APT_LOG" \ && grep -qE 'install .*docker-compose-v2' "$APT_LOG" \ && printf '%s' "$out" | grep -q 'docker compose'; then ok "ensure_compose_plugin tries plugin → docker-compose-v2 → warn-and-continue (never aborts)" else bad "ensure_compose_plugin rc=$rc; apt saw: $(tr '\n' ';' < "$APT_LOG")" fi # -------------------------------------------------------------------------- # T24 — I10: dnf5 (Fedora 41+) removed `config-manager --add-repo URL`; # docker_repo_add must fall back to the dnf5 `addrepo --from-repofile=` form, # and stay warn-and-continue when both forms fail. # -------------------------------------------------------------------------- t="$WORK/t24"; mkdir -p "$t/bin" DNF_LOG="$t/dnf.log"; : > "$DNF_LOG" cat > "$t/bin/dnf" <> "$DNF_LOG" case "\$*" in "config-manager --add-repo "*) exit 1 ;; # dnf5: flag removed "config-manager addrepo "*) exit 0 ;; esac exit 0 EOF chmod +x "$t/bin/dnf" if ( set -Eeuo pipefail; PATH="$t/bin:$PATH" docker_repo_add https://example.invalid/docker-ce.repo ) >/dev/null 2>&1 \ && grep -q 'config-manager addrepo --from-repofile=https://example.invalid/docker-ce.repo' "$DNF_LOG"; then ok "docker_repo_add falls back to the dnf5 addrepo syntax when --add-repo fails" else bad "docker_repo_add never tried the dnf5 syntax; dnf saw: $(tr '\n' ';' < "$DNF_LOG")" fi printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/dnf"; chmod +x "$t/bin/dnf" if ( set -Eeuo pipefail; PATH="$t/bin:$PATH" docker_repo_add https://example.invalid/docker-ce.repo ) >/dev/null 2>&1; then ok "docker_repo_add warns and continues when both config-manager forms fail" else bad "docker_repo_add aborted when both config-manager forms failed (must warn-and-continue)" fi # -------------------------------------------------------------------------- # T25 — I21: a failing state_set/state_append (e.g. unwritable # install-state.json) must not abort the firewall phase whose actual firewall # work already succeeded. The state file is pointed under a regular FILE so # state.sh's python genuinely fails; ufw is stubbed so firewall_open runs. # -------------------------------------------------------------------------- if command -v python3 >/dev/null 2>&1; then t="$WORK/t25"; mkdir -p "$t/bin" printf '#!/usr/bin/env bash\nexit 0\n' > "$t/bin/ufw"; chmod +x "$t/bin/ufw" : > "$t/blocker" # regular file — state.json can't live under it out="$( set -Eeuo pipefail export PATH="$t/bin:$PATH" export SERVERKIT_STATE_FILE="$t/blocker/state.json" FIREWALL_BACKEND=ufw configure_firewall 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Firewall configured'; then ok "configure_firewall survives failing state_set/state_append (records are best-effort)" else bad "configure_firewall rc=$rc when state writes fail (I21 regression): [$out]" fi else skip "configure_firewall state-failure guard — python3 unavailable here" fi # -------------------------------------------------------------------------- # T26 — regression guards baked into the source (same spirit as # test_update.sh T26): the audited failure shapes must never creep back in. # -------------------------------------------------------------------------- guards_ok=1 if grep -qE '^[^#]*backend/src' "$INSTALL_SH"; then bad "install.sh probes backend/src again — that path never exists (I4 detection regression)" guards_ok=0 fi if ! grep -qE '^[[:space:]]*upgrade_rhel_crypto_stack$' "$INSTALL_SH"; then bad "main() no longer calls upgrade_rhel_crypto_stack (I5 regression)" guards_ok=0 fi [ "$guards_ok" = "1" ] && ok "source guards: no backend/src probe; main runs the RHEL crypto upgrade" # -------------------------------------------------------------------------- # T27 — I11: nothing may write to the host before the root check. main() must # run preflight BEFORE choose_pkg_manager (which drops the apt lock-wait file # into /etc), and inside preflight the root check must come first — so a # non-root run halts on the friendly message, not a raw "Permission denied". # Behavioral half is dual: as non-root the friendly halt fires even with NO # df/free on PATH (the check precedes every probe); as root (distro-container # CI) preflight passes on stubbed df/free and probes the actual install # target with `df -Pk` (I14). # -------------------------------------------------------------------------- main_body="$(awk '/^main\(\) \{/,/^\}/' "$INSTALL_SH")" pf_at="$(printf '%s\n' "$main_body" | grep -n '^[[:space:]]*preflight$' | cut -d: -f1 | head -1)" pkg_at="$(printf '%s\n' "$main_body" | grep -n '^[[:space:]]*choose_pkg_manager$' | cut -d: -f1 | head -1)" if [ -n "$pf_at" ] && [ -n "$pkg_at" ] && [ "$pf_at" -lt "$pkg_at" ]; then ok "main() runs preflight (root check) before choose_pkg_manager's host write" else bad "main() order wrong: preflight at [$pf_at], choose_pkg_manager at [$pkg_at] (I11 regression)" fi t="$WORK/t27"; mkdir -p "$t/bin" "$t/deep" if [ "$(id -u)" -ne 0 ]; then out="$( set -Eeuo pipefail; PATH="$t/bin"; preflight 2>&1 )" rc=$? if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'as root'; then ok "preflight halts a non-root run on the friendly message before any probe or write" else bad "preflight (non-root) rc=$rc without the friendly root halt (I11 regression): [$out]" fi else DF_LOG="$t/df.log"; : > "$DF_LOG" cat > "$t/bin/df" <> "$DF_LOG" printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' printf '/dev/sda1 99999999 1 99999999 1%% /\n' EOF chmod +x "$t/bin/df" printf '#!/usr/bin/env bash\nprintf "Mem: 4096 1024 3072 0 0 3072\\n"\n' > "$t/bin/free" chmod +x "$t/bin/free" out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; INSTALL_DIR="$t/deep/nested/target" preflight 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Pre-flight checks passed' && \ grep -q -- "-Pk $t/deep" "$DF_LOG"; then ok "preflight (root) probes the install target's filesystem with df -Pk (I14)" else bad "preflight (root) rc=$rc; df saw: $(tr '\n' ';' < "$DF_LOG") (I14 regression): [$out]" fi fi # -------------------------------------------------------------------------- # T28 — I14: `free` is absent from some LXC templates; gauge_memory must # degrade to no-safe-mode with a warning, not abort on the bare assignment # under pipefail. # -------------------------------------------------------------------------- t="$WORK/t28"; mkdir -p "$t/nofree" if ! res="$( set -Eeuo pipefail; PATH="$t/nofree"; SAFE_MODE=true gauge_memory >/dev/null 2>&1 && printf '%s' "$SAFE_MODE" )"; then bad "gauge_memory aborted without 'free' under set -Eeuo pipefail (I14 regression)" elif [ "$res" = "false" ]; then ok "gauge_memory degrades gracefully when 'free' is absent (LXC templates)" else bad "gauge_memory aborted or left SAFE_MODE=[$res] without 'free' (I14 regression)" fi # -------------------------------------------------------------------------- # T29 — I19: ensure_swap must verify swap actually came up before claiming # "Swap active." (on btrfs/containers swapon silently fails and the Vite # build later OOMs), and a full disk (fallocate AND dd fail) must degrade to # a warning, never an abort. # -------------------------------------------------------------------------- t="$WORK/t29"; mkdir -p "$t/bin" printf '#!/usr/bin/env bash\nprintf "Mem: 2048 512 1536 0 0 1536\\nSwap: 0 0 0\\n"\n' > "$t/bin/free" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/fallocate" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/dd" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/mkswap" printf '#!/usr/bin/env bash\nexit 1\n' > "$t/bin/swapon" chmod +x "$t/bin/"* out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH" SERVERKIT_SWAPFILE="$t/swapfile" SERVERKIT_PROC_SWAPS="$t/proc_swaps" ensure_swap 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'Could not activate swap' && \ ! printf '%s' "$out" | grep -q 'Swap active'; then ok "ensure_swap warns and continues when no swap can be brought up (full disk / btrfs)" else bad "ensure_swap rc=$rc or claimed swap it never activated (I19 regression): [$out]" fi PROCS="$t/proc_swaps2"; : > "$PROCS" cat > "$t/bin/fallocate" <<'FALLOC_EOF' #!/usr/bin/env bash touch "${@: -1}" FALLOC_EOF cat > "$t/bin/swapon" </dev/null; exit 0; fi printf '%s\n' "\$1" >> "$PROCS" EOF printf '#!/usr/bin/env bash\nexit 0\n' > "$t/bin/mkswap" chmod +x "$t/bin/"* if ! out="$( set -Eeuo pipefail; PATH="$t/bin:$PATH" SERVERKIT_SWAPFILE="$t/swapfile2" SERVERKIT_PROC_SWAPS="$PROCS" ensure_swap 2>&1 )"; then bad "ensure_swap returned non-zero on the success path" elif printf '%s' "$out" | grep -q 'Swap active' && grep -q "$t/swapfile2" "$PROCS"; then ok "ensure_swap claims success only after swapon verifiably activated the file" else bad "ensure_swap did not verify/activate swap on the success path (I19): [$out]" fi # -------------------------------------------------------------------------- # T30 — I13: the get.docker.com convenience script (the DEFAULT Debian/Ubuntu # path) must be staged to a file with curl --retry and run from disk — never # piped straight into sh — and a failed download must warn and fall back to # the distro package instead of aborting. Curated PATH: exec-wrappers for the # real tools plus curl/apt-get stubs, and crucially NO docker (the global # stub would short-circuit provision_docker). # -------------------------------------------------------------------------- t="$WORK/t30"; mkdir -p "$t/bin" # Absolute-path shebangs: this test runs with PATH="$t/bin" ONLY (so that # `command -v docker` genuinely fails), which means `/usr/bin/env bash` could # not resolve bash for the stubs themselves. REAL_BASH="${BASH:-$(command -v bash)}" for tool in sh mktemp rm tail; do printf '#!%s\nexec %s "$@"\n' "$REAL_BASH" "$(command -v "$tool")" > "$t/bin/$tool" chmod +x "$t/bin/$tool" done CURL_LOG="$t/curl.log"; : > "$CURL_LOG" cat > "$t/bin/curl" <> "$CURL_LOG" out="" while [ \$# -gt 0 ]; do if [ "\$1" = "-o" ]; then out="\$2"; shift; fi shift done if [ -n "\$out" ]; then printf 'echo ran > "%s"\n' "$t/get-docker-ran" > "\$out"; fi exit 0 EOF chmod +x "$t/bin/curl" if ( set -Eeuo pipefail; PATH="$t/bin" OS_FAMILY=debian PKG_MGR=apt provision_docker ) >/dev/null 2>&1 \ && [ -f "$t/get-docker-ran" ] && grep -q -- '--retry 3' "$CURL_LOG"; then ok "provision_docker stages get.docker.com to a file with retries and executes it (no curl|sh)" else bad "provision_docker did not stage/run the docker script (I13); curl saw: $(tr '\n' ';' < "$CURL_LOG")" fi APT_LOG="$t/apt.log"; : > "$APT_LOG" printf '#!%s\nexit 22\n' "$REAL_BASH" > "$t/bin/curl"; chmod +x "$t/bin/curl" cat > "$t/bin/apt-get" <> "$APT_LOG" exit 100 EOF chmod +x "$t/bin/apt-get" out="$( set -Eeuo pipefail; PATH="$t/bin" OS_FAMILY=debian PKG_MGR=apt provision_docker 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'get.docker.com' && \ grep -qE 'install .*docker\.io' "$APT_LOG"; then ok "provision_docker warns and falls back to the distro package when the download fails" else bad "provision_docker rc=$rc on a failed download (I13); apt saw: $(tr '\n' ';' < "$APT_LOG"): [$out]" fi # -------------------------------------------------------------------------- # T31 — I15: the release tag must come from the releases/latest redirect # (no API quota) with api.github.com only as a fallback — the 60-req/hr # unauthenticated API limit is routinely exhausted behind shared cloud NAT — # and an unresolvable tag must produce a LOUD source-build warning, never the # old silent downgrade. # -------------------------------------------------------------------------- t="$WORK/t31"; mkdir -p "$t/bin" CURL_LOG="$t/curl.log"; : > "$CURL_LOG" cat > "$t/bin/curl" <> "$CURL_LOG" case "\$*" in *github.com/*/releases/latest*) printf 'https://github.com/x/y/releases/tag/v9.9.9'; exit 0 ;; esac exit 22 EOF chmod +x "$t/bin/curl" if ! res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; SERVERKIT_VERSION="" resolve_release_tag )"; then bad "resolve_release_tag (redirect path) returned non-zero" elif [ "$res" = "v9.9.9" ] && ! grep -q 'api.github.com' "$CURL_LOG"; then ok "resolve_release_tag reads the tag from the releases/latest redirect (no API quota burned)" else bad "resolve_release_tag primary path returned [$res]; curl saw: $(tr '\n' ';' < "$CURL_LOG")" fi : > "$CURL_LOG" cat > "$t/bin/curl" <> "$CURL_LOG" case "\$*" in *api.github.com*) printf '{"tag_name": "v8.8.8",\n'; exit 0 ;; esac exit 22 EOF chmod +x "$t/bin/curl" if ! res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; SERVERKIT_VERSION="" resolve_release_tag )"; then bad "resolve_release_tag (API fallback) returned non-zero" elif [ "$res" = "v8.8.8" ]; then ok "resolve_release_tag falls back to the GitHub API when the redirect is unreachable" else bad "resolve_release_tag API fallback returned [$res] (I15)" fi printf '#!/usr/bin/env bash\nexit 22\n' > "$t/bin/curl"; chmod +x "$t/bin/curl" res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH" SERVERKIT_OFFLINE_TARBALL=""; SERVERKIT_VERSION=""; INSTALL_FROM_RELEASE=1 rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$? printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )" if [ "$res" = "1|0" ] && grep -q 'SOURCE BUILD' "$t/fetch.out"; then ok "fetch_release warns LOUDLY and flips to source when no tag resolves (no silent downgrade)" else bad "fetch_release tag-fallback wrong: state [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)" fi # T31b — a missing checksums.txt used to be "skipping verification" and the # unverified tarball was installed. It must fall back to source instead, # removing the download, unless SERVERKIT_ALLOW_UNVERIFIED=1. cat > "$t/bin/curl" < "\$out" ;; *) exit 22 ;; esac EOF chmod +x "$t/bin/curl" res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH" SERVERKIT_OFFLINE_TARBALL=""; SERVERKIT_VERSION="v9.9.9"; INSTALL_FROM_RELEASE=1 SERVERKIT_MIRROR_URL="http://mirror.invalid"; DL_ARCH=amd64 resolve_release_tag() { echo v9.9.9; } rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$? left=no; [ -e /tmp/serverkit-v9.9.9-linux-amd64.tar.gz ] && left=yes printf '%s|%s|%s' "$rc" "$INSTALL_FROM_RELEASE" "$left" )" if [ "$res" = "1|0|no" ] && grep -q 'not installing an unverified release' "$t/fetch.out"; then ok "fetch_release falls back to source when checksums.txt is missing (no unverified install)" else bad "fetch_release with no checksums.txt: state [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)" fi rm -f /tmp/serverkit-v9.9.9-linux-amd64.tar.gz # -------------------------------------------------------------------------- # T32 — I16: failures inside fetch_release's unpack/copy (corrupt download, # disk full) must take the clean fallback-to-source path — return nonzero so # main()'s existing fallback fires — never a half-copied tree that "succeeds" # and fails confusingly later. The cp half also proves the live .env is put # back before handing over (symlink layout: skipped on Git Bash, runs on CI). # -------------------------------------------------------------------------- t="$WORK/t32"; mkdir -p "$t" printf 'this is not a tarball\n' > "$t/garbage.tar.gz" res="$( set -Eeuo pipefail INSTALL_DIR="$t/opt/sk"; DIR_A="$t/opt/sk-a"; DIR_B="$t/opt/sk-b"; FIRST_SLOT="$t/opt/sk-a" SERVERKIT_OFFLINE_TARBALL="$t/garbage.tar.gz"; INSTALL_FROM_RELEASE=1 rc=0; fetch_release >"$t/fetch.out" 2>&1 || rc=$? printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )" if [ "$res" = "1|0" ] && grep -q 'falling back to source' "$t/fetch.out"; then ok "fetch_release: a failed tar unpack takes the clean source fallback, not a fake success" else bad "fetch_release tar-failure path wrong (I16 regression): [$res]: $(tr '\n' ';' < "$t/fetch.out" 2>/dev/null)" fi t="$WORK/t32c"; slotA="$t/sk-a"; slotB="$t/sk-b"; inst="$t/sk" mkdir -p "$slotA/backend/instance" "$t/bin" printf 'SECRET_KEY=survive-cp\n' > "$slotA/.env" cp "$slotA/.env" "$t/env.orig" ln -sfn "$slotA" "$inst" 2>/dev/null || true if [ ! -L "$inst" ]; then skip "fetch_release cp-failure fallback — symlinks unsupported here (runs on Linux CI)" elif ! command -v tar >/dev/null 2>&1; then skip "fetch_release cp-failure fallback — tar unavailable here" else stage="$t/stage"; mkdir -p "$stage/serverkit/backend/app" printf '#!/bin/sh\n' > "$stage/serverkit/serverkit" tar czf "$t/rel.tar.gz" -C "$stage" serverkit cat > "$t/bin/cp" </dev/null 2>&1 || rc=$? printf '%s|%s' "$rc" "$INSTALL_FROM_RELEASE" )" if [ "$res" = "1|0" ] && files_identical "$slotA/.env" "$t/env.orig"; then ok "fetch_release: a failed slot copy restores the live .env and falls back to source" else bad "fetch_release cp-failure lost live state or faked success (I16 regression): [$res]" fi fi # -------------------------------------------------------------------------- # T33 — I17: the inline service verbs (mirroring scripts/lib/init.sh, which # install.sh cannot source pre-clone) must drive systemctl on systemd boxes, # fall back to OpenRC/SysV elsewhere, and warn-and-continue when nothing can # be driven. INIT_OVERRIDE pins detection (same contract as init.sh). # -------------------------------------------------------------------------- t="$WORK/t33"; mkdir -p "$t/sysd" "$t/rc" "$t/none" SVC_LOG="$t/svc.log"; : > "$SVC_LOG" cat > "$t/sysd/systemctl" <> "$SVC_LOG" EOF chmod +x "$t/sysd/systemctl" svc_rc=0 ( set -Eeuo pipefail; PATH="$t/sysd:$PATH" INIT_OVERRIDE=systemd svc_enable nginx \ && INIT_OVERRIDE=systemd svc_start nginx ) >/dev/null 2>&1 || svc_rc=$? if [ "$svc_rc" -eq 0 ] && grep -q 'systemctl enable nginx' "$SVC_LOG" && grep -q 'systemctl start nginx' "$SVC_LOG"; then ok "svc_enable/svc_start drive systemctl on systemd boxes" else bad "svc verbs rc=$svc_rc or missed systemctl (I17): $(tr '\n' ';' < "$SVC_LOG")" fi : > "$SVC_LOG" cat > "$t/rc/rc-update" <> "$SVC_LOG" EOF cat > "$t/rc/rc-service" <> "$SVC_LOG" EOF chmod +x "$t/rc/"* # Full PATH kept (the stubs' env-bash shebang needs it); the override makes # svc_has_systemd false, so the real systemctl is never consulted. ( set -Eeuo pipefail; PATH="$t/rc:$PATH" INIT_OVERRIDE=openrc svc_enable docker; INIT_OVERRIDE=openrc svc_start docker ) >/dev/null 2>&1 rc=$? if [ "$rc" -eq 0 ] && grep -q 'rc-update add docker default' "$SVC_LOG" && \ grep -q 'rc-service docker start' "$SVC_LOG"; then ok "svc verbs fall back to OpenRC without systemd (no bare-systemctl abort)" else bad "svc OpenRC fallback rc=$rc (I17): $(tr '\n' ';' < "$SVC_LOG")" fi out="$( set -Eeuo pipefail; PATH="$t/none" INIT_OVERRIDE=none svc_start nginx 2>&1; INIT_OVERRIDE=none svc_enable nginx 2>&1 )" rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q 'No init system'; then ok "svc verbs warn-and-continue when no init system can be driven (WSL/containers)" else bad "svc verbs rc=$rc with no init system — must warn and return 0 (I17): [$out]" fi # -------------------------------------------------------------------------- # T34 — I18: migrating a legacy real-directory install while a stale slot # from an aborted earlier run exists must not `mv` the live tree INSIDE it # (/opt/serverkit-a/serverkit); the stale slot is cleared first. # -------------------------------------------------------------------------- t="$WORK/t34"; mkdir -p "$t/opt/serverkit" "$t/opt/serverkit-a/stale" printf 'live\n' > "$t/opt/serverkit/marker" # Deliberately status-unchecked: ensure_install_layout's trailing `ln -s` # legitimately fails on Git Bash (MSYS "symlinks" are copies, so the -L probe # is false and the re-link hits an existing dir); the file assertions below # fully constrain the behaviour under test. On Linux CI the subshell exits 0. ( set -Eeuo pipefail INSTALL_DIR="$t/opt/serverkit"; DIR_A="$t/opt/serverkit-a"; DIR_B="$t/opt/serverkit-b" FIRST_SLOT="$t/opt/serverkit-a" ensure_install_layout ) >/dev/null 2>&1 if [ -f "$t/opt/serverkit-a/marker" ] && [ ! -e "$t/opt/serverkit-a/serverkit" ]; then ok "ensure_install_layout clears a stale slot instead of nesting the live tree inside it" else bad "ensure_install_layout nested/lost the live tree on a stale-slot collision (I18 regression)" fi # -------------------------------------------------------------------------- # T35 — I22: /etc/os-release defines its own VERSION ("24.04.1 LTS ..."), # which used to clobber the installer's version string when sourced. # -------------------------------------------------------------------------- t="$WORK/t35"; mkdir -p "$t" cat > "$t/os-release" <<'OSR_EOF' ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 24.04 LTS" VERSION="24.04.1 LTS (Noble Numbat)" OSR_EOF if ! res="$( set -Eeuo pipefail VERSION="sk-sentinel" SERVERKIT_OS_RELEASE="$t/os-release" identify_system >/dev/null 2>&1 \ && printf '%s|%s' "$VERSION" "$OS_FAMILY" )"; then bad "identify_system aborted under set -Eeuo pipefail" elif [ "$res" = "sk-sentinel|debian" ]; then ok "identify_system keeps the installer's \$VERSION across the os-release source" else bad "identify_system clobbered VERSION/OS_FAMILY (I22 regression): [$res]" fi # -------------------------------------------------------------------------- # T36 — I23: ping_telemetry runs AFTER a successful install; a missing # VERSION file (pipefail on cat|tr) must never abort it. Empty PATH also # proves the curl is best-effort. # -------------------------------------------------------------------------- t="$WORK/t36"; mkdir -p "$t/empty" "$t/noinstall" if ( set -Eeuo pipefail; PATH="$t/empty"; INSTALL_DIR="$t/noinstall" ping_telemetry ) >/dev/null 2>&1; then ok "ping_telemetry survives a missing VERSION file after a successful install" else bad "ping_telemetry DIED on a missing VERSION under pipefail (I23 regression)" fi # -------------------------------------------------------------------------- # T37 — I24: write_config's early return must still refresh the SSL-mode key # in a pre-existing .env — a re-run that gained/lost HTTPS otherwise leaves # the panel's HSTS gate reading a stale value — without touching the # irreplaceable generated secrets. # -------------------------------------------------------------------------- t="$WORK/t37"; mkdir -p "$t/inst" printf 'SECRET_KEY=sentinel-keep\nSERVERKIT_SSL_MODE=insecure\nPORT=80\n' > "$t/inst/.env" wc_rc=0 ( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; SSL_MODE=secure; write_config ) >/dev/null 2>&1 || wc_rc=$? if [ "$wc_rc" -eq 0 ] && grep -q '^SERVERKIT_SSL_MODE=secure$' "$t/inst/.env" && \ grep -q '^SECRET_KEY=sentinel-keep$' "$t/inst/.env" && \ [ "$(grep -c '^SERVERKIT_SSL_MODE=' "$t/inst/.env")" = "1" ]; then ok "write_config early-return refreshes SERVERKIT_SSL_MODE in an existing .env (secrets intact)" else bad "write_config rc=$wc_rc or left a stale/duplicated SSL mode in the existing .env (I24 regression)" fi printf 'SECRET_KEY=nokey-line\n' > "$t/inst/.env" wc_rc=0 ( set -Eeuo pipefail; INSTALL_DIR="$t/inst"; SSL_MODE=insecure; write_config ) >/dev/null 2>&1 || wc_rc=$? if [ "$wc_rc" -eq 0 ] && grep -q '^SERVERKIT_SSL_MODE=insecure$' "$t/inst/.env" && grep -q '^SECRET_KEY=nokey-line$' "$t/inst/.env"; then ok "write_config appends SERVERKIT_SSL_MODE when a pre-existing .env lacks it" else bad "write_config rc=$wc_rc or did not add the missing SSL-mode key to a pre-existing .env (I24)" fi # -------------------------------------------------------------------------- # T38 — apply_frontend_root: same end-of-function `[ -f ] && ...` species as # I1/I2, reachable with a custom SERVERKIT_DIR when the last conf is missing. # -------------------------------------------------------------------------- t="$WORK/t38"; mkdir -p "$t" printf 'root /opt/serverkit/frontend/dist;\n' > "$t/site.conf" if ( set -Eeuo pipefail; INSTALL_DIR="$t/custom" apply_frontend_root "$t/site.conf" "$t/does-not-exist.conf" ) >/dev/null 2>&1 \ && grep -q "root $t/custom/frontend/dist;" "$t/site.conf"; then ok "apply_frontend_root rewrites the root and survives a missing conf as the last arg" else bad "apply_frontend_root aborted on a missing conf under set -e (the I1/I2 species)" fi # -------------------------------------------------------------------------- # T39 — static guards for this slice's one-liner shapes (same spirit as T26). # -------------------------------------------------------------------------- guards2_ok=1 if grep -qE '^[^#]*get\.docker\.com[^|#]*\|[[:space:]]*sh' "$INSTALL_SH"; then bad "install.sh pipes get.docker.com straight into sh again (I13 regression)" guards2_ok=0 fi if grep -qE '^[^#]*df /opt' "$INSTALL_SH"; then bad "preflight hardcodes 'df /opt' again — must probe \$INSTALL_DIR with df -Pk (I14 regression)" guards2_ok=0 fi if ! grep -q 'svc_enable nginx' "$INSTALL_SH" || ! grep -q 'svc_start serverkit' "$INSTALL_SH"; then bad "nginx/serverkit are enabled/started with bare systemctl again (I17 regression)" guards2_ok=0 fi [ "$guards2_ok" = "1" ] && ok "source guards: no curl|sh docker pipe, no 'df /opt', svc verbs at the call sites" # -------------------------------------------------------------------------- # T40 — the fresh-minimal-box loop (twin of test_update.sh's; see the # 2026-07-02 outage note there). EVERY observation/discovery/snapshot/report/ # probe function in install.sh must survive the emptiest valid world a fresh # box presents — nothing installed yet, zero apps, zero containers, no # optional confs, dead network, empty state — under set -Eeuo pipefail. # # POLICY: every new observation/discovery/snapshot/report function added to # install.sh MUST be appended here (add args/disposition to the case table # when needed). Dispositions: # must0 — output/result is captured by assignment somewhere (X="$(fn)"), so # a non-zero exit IS the outage: the function must exit 0. # pred — used only in conditional position (if fn; ...) or legitimately # environment-dependent (root checks, host tool probes): it must # merely return normally (no set -u crash, no signal death). # -------------------------------------------------------------------------- FRESH="$WORK/freshbox" make_fresh_box_fixture "$FRESH" # -------------------------------------------------------------------------- # T31 — install profiles: recommend_profile must read the hardware, and the # minimal profile must genuinely skip Docker. A profile is what we install, # not a licence tier, so the only thing under test here is "did the right # provisioning get skipped". # -------------------------------------------------------------------------- t="$WORK/t31"; mkdir -p "$t/bin" mkfree() { # mkfree printf '#!/usr/bin/env bash\nprintf " total\\nMem: %s 0 0\\nSwap: 0 0 0\\n"\n' "$1" \ > "$t/bin/free" chmod +x "$t/bin/free" } mknproc() { printf '#!/usr/bin/env bash\nprintf "%s"\n' "$1" > "$t/bin/nproc"; chmod +x "$t/bin/nproc"; } mkdf() { # mkdf printf '#!/usr/bin/env bash\nprintf "FS 1G 1G %sG 1%%%% /\\nFS 1G 1G %sG 1%%%% /\\n"\n' "$1" "$1" \ > "$t/bin/df" chmod +x "$t/bin/df" } # 700MB box → minimal, no matter how many cores it claims. mkfree 700; mknproc 8; mkdf 100 res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t" RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1 printf '%s' "$RECOMMENDED_PROFILE" )" if [ "$res" = "minimal" ]; then ok "recommend_profile: 700MB box → minimal" else bad "recommend_profile: 700MB box gave [$res], expected minimal" fi # 2GB / 2 cores → standard. mkfree 2048; mknproc 2; mkdf 50 res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t" RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1 printf '%s' "$RECOMMENDED_PROFILE" )" if [ "$res" = "standard" ]; then ok "recommend_profile: 2GB/2-core box → standard" else bad "recommend_profile: 2GB/2-core box gave [$res], expected standard" fi # 8GB / 4 cores / 50GB free → full. mkfree 8192; mknproc 4; mkdf 50 res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t" RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1 printf '%s' "$RECOMMENDED_PROFILE" )" if [ "$res" = "full" ]; then ok "recommend_profile: 8GB/4-core box → full" else bad "recommend_profile: 8GB/4-core box gave [$res], expected full" fi # A roomy box with a nearly-full disk still drops to minimal — images and # backups need somewhere to land before RAM is the binding constraint. mkfree 8192; mknproc 8; mkdf 2 res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t" RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1 printf '%s' "$RECOMMENDED_PROFILE" )" if [ "$res" = "minimal" ]; then ok "recommend_profile: 8GB box with 2GB disk free → minimal" else bad "recommend_profile: low-disk box gave [$res], expected minimal" fi # No `free` at all (LXC templates again) must not abort under pipefail. mkdir -p "$t/empty" if res="$( set -Eeuo pipefail; PATH="$t/empty"; BASE_DIR="$t" RECOMMENDED_PROFILE=""; recommend_profile >/dev/null 2>&1 printf '%s' "$RECOMMENDED_PROFILE" )"; then ok "recommend_profile survives a box with no free/nproc/df (got [$res])" else bad "recommend_profile aborted with no free/nproc/df under set -Eeuo pipefail" fi # The whole point of minimal: provision_docker must not touch the box. out="$( set -Eeuo pipefail; PROFILE=minimal provision_docker 2>&1 )" if printf '%s' "$out" | grep -q 'Skipping Docker'; then ok "provision_docker is a no-op on the minimal profile" else bad "provision_docker ran on the minimal profile: [$(printf '%s' "$out" | tail -c 120)]" fi out="$( set -Eeuo pipefail; PROFILE=minimal ensure_compose_plugin 2>&1 )" if [ -z "$out" ]; then ok "ensure_compose_plugin is a no-op on the minimal profile" else bad "ensure_compose_plugin ran on the minimal profile: [$out]" fi # "full" has to earn its name — it was byte-identical to "standard" until # provision_hardening existed, so the card promised things nothing installed. pkgt="$WORK/t31pkg"; mkdir -p "$pkgt" out="$( set -Eeuo pipefail; PROFILE=minimal; OS_FAMILY=debian; PKG_MGR=apt SERVERKIT_SKIP_SSL=0 pkg_add() { printf 'PKGADD:%s\n' "$*"; } provision_hardening 2>&1 )" if [ -z "$out" ]; then ok "provision_hardening is a no-op on the minimal profile" else bad "provision_hardening ran on minimal: [$out]" fi # fail2ban is standard-and-up (plan 73 item 8): the small VPS is exactly the box # that gets SSH-brute-forced, so leaving it to "full" meant the typical install # got no jail at all. certbot stays a "full" extra — HTTPS is optional. out="$( set -Eeuo pipefail; PROFILE=standard; OS_FAMILY=debian; PKG_MGR=apt SERVERKIT_SKIP_SSL=0 pkg_add() { printf 'PKGADD:%s\n' "$*"; } svc_enable() { :; }; svc_start() { :; } provision_hardening 2>&1 )" if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \ ! printf '%s' "$out" | grep -q 'PKGADD:certbot'; then ok "provision_hardening installs fail2ban (not certbot) on the standard profile" else bad "standard profile hardening is wrong: [$(printf '%s' "$out" | tr '\n' ' ')]" fi out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt SERVERKIT_SKIP_SSL=0 pkg_add() { printf 'PKGADD:%s\n' "$*"; } svc_enable() { :; }; svc_start() { :; } provision_hardening 2>&1 )" if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \ printf '%s' "$out" | grep -q 'PKGADD:certbot'; then ok "provision_hardening installs fail2ban + certbot on the full profile" else bad "full profile did not provision the hardening stack: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # HTTPS off means certbot is pointless; fail2ban still applies. out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt SERVERKIT_SKIP_SSL=1 pkg_add() { printf 'PKGADD:%s\n' "$*"; } svc_enable() { :; }; svc_start() { :; } provision_hardening 2>&1 )" if printf '%s' "$out" | grep -q 'PKGADD:fail2ban' && \ ! printf '%s' "$out" | grep -q 'PKGADD:certbot'; then ok "provision_hardening skips certbot when SERVERKIT_SKIP_SSL=1" else bad "SERVERKIT_SKIP_SSL=1 did not suppress certbot: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # A failing package must warn, never abort the install (pkg_add's contract). if out="$( set -Eeuo pipefail; PROFILE=full; OS_FAMILY=debian; PKG_MGR=apt SERVERKIT_SKIP_SSL=0 pkg_add() { return 0; } svc_enable() { :; }; svc_start() { :; } provision_hardening 2>&1 )"; then ok "provision_hardening survives packages that never appear" else bad "provision_hardening aborted when a hardening package failed to install" fi # provision_hardening must run before configure_nginx — certbot has to be on # the box before the HTTPS attempt, or full silently degrades to plain HTTP. # Match call lines only — a nearby comment mentioning configure_nginx would # otherwise satisfy the ordering grep. if awk '/^main\(\)/,/^}/' "$INSTALL_SH" \ | grep -E '^[[:space:]]*(provision_hardening|configure_nginx)[[:space:]]*$' \ | head -2 | tr '\n' ' ' | grep -q 'provision_hardening.*configure_nginx'; then ok "main() runs provision_hardening before configure_nginx" else bad "provision_hardening runs after configure_nginx — certbot would arrive too late" fi # An explicit SERVERKIT_PROFILE must be honoured verbatim and never prompt. res="$( set -Eeuo pipefail; PROFILE="full" prompt_for_profile >/dev/null 2>&1; printf '%s' "$PROFILE" )" if [ "$res" = "full" ]; then ok "prompt_for_profile honours an explicit SERVERKIT_PROFILE" else bad "prompt_for_profile clobbered an explicit profile: got [$res]" fi # A garbage value falls back to detection rather than installing "banana". mkfree 2048; mknproc 2; mkdf 50 res="$( set -Eeuo pipefail; PATH="$t/bin:$PATH"; BASE_DIR="$t"; PROFILE="banana" prompt_for_profile >/dev/null 2>&1 /dev/null 2>&1 &1 )" rc=$? if [ "$mode" = "must0" ] && [ "$rc" -ne 0 ]; then bad "fresh-box loop: $fn exited $rc on a fresh box: [$(printf '%s' "$out" | tail -c 160)]" loop_fail=1 elif [ "$mode" = "pred" ] && { [ "$rc" -gt 128 ] || printf '%s' "$out" | grep -q 'unbound variable'; }; then bad "fresh-box loop: predicate $fn crashed (rc=$rc) on a fresh box: [$(printf '%s' "$out" | tail -c 160)]" loop_fail=1 fi done if [ "$loop_fail" = "0" ]; then ok "fresh-box loop: all ${#INSTALL_OBSERVERS[@]} observation/discovery functions survive a fresh box" fi # -------------------------------------------------------------------------- # T41 — the source guard must not break piped execution (`curl … | bash`, the # one-liner README/DEPLOYMENT document). Piped, bash leaves BASH_SOURCE unset, # so a bare ${BASH_SOURCE[0]} is an unbound variable under `set -u` and aborts # before main() ever runs — which is exactly what shipped and broke every # curl-piped install ("line NNNN: BASH_SOURCE[0]: unbound variable"). # # CI only ever runs `bash install.sh` from a checkout, where BASH_SOURCE IS # set, so the matrix cannot see this. Running the real installer piped would # install a server, so exercise the shipped tail — the guard plus the `main # "$@"` dispatch, extracted verbatim — against a harmless main() stub. # -------------------------------------------------------------------------- guard_ln="$(grep -n 'BASH_SOURCE' "$INSTALL_SH" | grep -vE ':[[:space:]]*#' | tail -1 | cut -d: -f1)" guard_tail="$WORK/t41_tail.sh" { printf 'set -euo pipefail\n' printf 'main() { printf "REACHED_MAIN"; }\n' sed -n "${guard_ln},\$p" "$INSTALL_SH" } > "$guard_tail" out="$(bash < "$guard_tail" 2>&1)"; rc=$? if [ "$rc" -eq 0 ] && [ "$out" = "REACHED_MAIN" ]; then ok "piped execution (stdin, as curl|bash) still reaches main()" else bad "piped execution broken by the source guard: rc=$rc out=[$out]" fi # The other half of the contract: a real `source` must still return early, or # this whole suite would run an install instead of unit-testing one. out="$( set -euo pipefail; source "$guard_tail"; printf 'EARLY_RETURN' )" if [ "$out" = "EARLY_RETURN" ]; then ok "sourcing still returns before main()" else bad "the source guard no longer short-circuits a source: out=[$out]" fi # -------------------------------------------------------------------------- # T42 — plan 73 item 8: a stock Debian/Ubuntu box gets a real firewall, and the # SSH port is discovered BEFORE default-deny is switched on. # # This is the single most dangerous thing the installer does: enabling # default-deny without allowing the port sshd actually answers on locks the # operator out over the very session running the install. Every case below is a # lockout-safety assertion. # # `sshd` and `ss` are stubbed to fail throughout so detection is forced down to # the config-file parser and the fixtures decide the answer — otherwise the # result would depend on whether the CI runner happens to run sshd. # -------------------------------------------------------------------------- t42="$WORK/t42"; mkdir -p "$t42/bin" "$t42/ssh/sshd_config.d" "$t42/state" make_stub_exit "$t42/bin" 1 sshd ss # -- detection: a drop-in Port wins over a commented-out one in the main file. printf '#Port 22\nPermitRootLogin no\n' > "$t42/ssh/sshd_config" printf 'Port 2222\n' > "$t42/ssh/sshd_config.d/99-port.conf" res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )" if [ "$res" = "2222 " ]; then ok "detect_ssh_ports reads sshd_config.d drop-ins and ignores commented Ports" else bad "detect_ssh_ports got [$res], expected the drop-in's 2222" fi # -- detection: several Port lines all come back, de-duplicated. printf 'Port 22\nPort 2222\nPort 22\n' > "$t42/ssh/sshd_config" : > "$t42/ssh/sshd_config.d/99-port.conf" res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )" if [ "$res" = "22 2222 " ]; then ok "detect_ssh_ports returns every declared port, de-duplicated" else bad "detect_ssh_ports got [$res], expected [22 2222 ]" fi # -- detection: NO sshd_config.d directory at all — the common shape, and the # one that bit hardest. The unmatched glob makes `cat` exit non-zero; under # the installer's `set -o pipefail` that wiped the parsed answer and the # function fell through to its "22" default. On a box whose sshd is on 2222 # that means UFW allows the wrong port and locks the operator out. Pinning # it here because the failure only ever shows up on a real server. t42b="$WORK/t42b"; mkdir -p "$t42b/ssh" printf 'Port 2222\n' > "$t42b/ssh/sshd_config" res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42b/ssh" detect_ssh_ports | tr '\n' ' ' )" if [ "$res" = "2222 " ]; then ok "detect_ssh_ports reads sshd_config with no drop-in dir (pipefail regression)" else bad "detect_ssh_ports got [$res] with no sshd_config.d — expected 2222, LOCKOUT RISK" fi # -- detection: a config that names no Port means sshd's built-in default. printf 'PermitRootLogin no\n' > "$t42/ssh/sshd_config" res="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/ssh" detect_ssh_ports | tr '\n' ' ' )" if [ "$res" = "22 " ]; then ok "detect_ssh_ports falls back to 22 when the config names no Port" else bad "detect_ssh_ports got [$res] for a Port-less config, expected 22" fi # -- detection: nothing to go on at all → NON-ZERO. This is the fail-safe the # bootstrap keys off; if it ever starts guessing, the guard is gone. if ( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/nonexistent" detect_ssh_ports >/dev/null 2>&1 ); then bad "detect_ssh_ports invented an SSH port with no config, no sshd and no ss" else ok "detect_ssh_ports fails loudly when no source can name a port" fi # -- bootstrap: the happy path. Allow rules for SSH/80/443 must all be issued # BEFORE `ufw default deny incoming`, and default-deny before `ufw enable`. printf 'Port 2222\n' > "$t42/ssh/sshd_config" out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; PANEL_PORT=80; FW_DRY_RUN=1; FIREWALL_BACKEND=none SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" command -v ufw >/dev/null 2>&1 || ufw() { :; } bootstrap_firewall 2>&1 )" allow_ssh_ln="$(printf '%s\n' "$out" | grep -n 'ufw allow 2222/tcp' | head -1 | cut -d: -f1)" allow_80_ln="$( printf '%s\n' "$out" | grep -n 'ufw allow 80/tcp' | head -1 | cut -d: -f1)" allow_443_ln="$(printf '%s\n' "$out" | grep -n 'ufw allow 443/tcp' | head -1 | cut -d: -f1)" deny_ln="$( printf '%s\n' "$out" | grep -n 'ufw default deny incoming' | head -1 | cut -d: -f1)" enable_ln="$( printf '%s\n' "$out" | grep -n 'ufw --force enable' | head -1 | cut -d: -f1)" if [ -n "$allow_ssh_ln" ] && [ -n "$allow_80_ln" ] && [ -n "$allow_443_ln" ] \ && [ -n "$deny_ln" ] && [ -n "$enable_ln" ] \ && [ "$allow_ssh_ln" -lt "$deny_ln" ] && [ "$allow_80_ln" -lt "$deny_ln" ] \ && [ "$allow_443_ln" -lt "$deny_ln" ] && [ "$deny_ln" -lt "$enable_ln" ]; then ok "bootstrap_firewall allows SSH/80/443 before default-deny, and denies before enabling" else bad "bootstrap_firewall rule ordering is unsafe: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # -- bootstrap: the interlock. No discoverable SSH port ⇒ NOTHING is enabled. out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none SSHD_CONFIG_ROOT="$t42/nonexistent" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" command -v ufw >/dev/null 2>&1 || ufw() { :; } bootstrap_firewall 2>&1 )" if ! printf '%s' "$out" | grep -q 'ufw --force enable' \ && ! printf '%s' "$out" | grep -q 'default deny' \ && printf '%s' "$out" | grep -qi 'NOT enabling'; then ok "bootstrap_firewall refuses to enable a firewall when the SSH port is unknown" else bad "bootstrap_firewall enabled default-deny without knowing the SSH port: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # -- bootstrap: a non-standard PANEL_PORT is allowed too, or default-deny takes # the panel UI down with it. printf 'Port 22\n' > "$t42/ssh/sshd_config" out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; PANEL_PORT=8443; FW_DRY_RUN=1; FIREWALL_BACKEND=none SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" command -v ufw >/dev/null 2>&1 || ufw() { :; } bootstrap_firewall 2>&1 )" if printf '%s' "$out" | grep -q 'ufw allow 8443/tcp'; then ok "bootstrap_firewall opens a non-standard PANEL_PORT before default-deny" else bad "bootstrap_firewall would lock the panel out on PANEL_PORT=8443: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # -------------------------------------------------------------------------- # "Is this box already protected?" — the question the bootstrap's skip-guard # has to ask. It originally asked firewall_detect() != none, which answers a # DIFFERENT question ("which backend do I open a port through") and returns # `nftables` for a bare nft binary with an empty ruleset. Every case below was # written after a real Ubuntu 24.04 VM install logged "via nftables" while the # bootstrap printed nothing at all. # -------------------------------------------------------------------------- fw_probe_dir() { # fw_probe_dir -> fresh bin dir on stdout local d="$t42/fw-$1"; rm -rf "$d"; mkdir -p "$d"; printf '%s' "$d" } run_bootstrap() { # run_bootstrap ( set -Eeuo pipefail; PATH="$1:$PATH" OS_FAMILY=debian; FW_DRY_RUN=1 SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" bootstrap_firewall 2>&1 ) } # -- The two predicates answer DIFFERENT questions, and conflating them is what # broke this. Pinned at the predicate level so the distinction survives any # future refactor of either one. d="$(fw_probe_dir predicates)" make_stub_stdout "$d" nft 0 &1 )" if [ -z "$out" ]; then ok "bootstrap_firewall is a no-op outside the Debian family" else bad "bootstrap_firewall tried to install UFW on a non-Debian box: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # -- bootstrap: SERVERKIT_SKIP_FIREWALL=1 and containers are both hands-off. out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none SERVERKIT_SKIP_FIREWALL=1; SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" bootstrap_firewall 2>&1 )" out2="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf 'lxc'; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" bootstrap_firewall 2>&1 )" if ! printf '%s' "$out" | grep -q 'ufw' && ! printf '%s' "$out2" | grep -q 'ufw allow'; then ok "bootstrap_firewall honours SERVERKIT_SKIP_FIREWALL and skips containers" else bad "bootstrap_firewall ignored an opt-out: [$out] [$out2]" fi # -- the re-run guard: we bootstrapped once, the operator switched the firewall # off, a later install must NOT switch it back on. out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" OS_FAMILY=debian; FW_DRY_RUN=1; FIREWALL_BACKEND=none SSHD_CONFIG_ROOT="$t42/ssh" detect_container() { printf ''; } pkg_add() { :; } source "$LIB_DIR/firewall.sh" state_get() { [ "$1" = "firewall_bootstrapped" ] && printf '1' || printf ''; } bootstrap_firewall 2>&1 )" if ! printf '%s' "$out" | grep -q 'ufw --force enable'; then ok "bootstrap_firewall does not re-enable a firewall the operator turned off" else bad "bootstrap_firewall re-enabled a deliberately disabled firewall: [$(printf '%s' "$out" | tr '\n' ' ')]" fi # -- the sshd jail watches the port sshd actually uses, not a hardcoded 22. printf 'Port 2222\n' > "$t42/ssh/sshd_config" jail_dir="$t42/jail.d" out="$( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/ssh"; FAIL2BAN_JAIL_DIR="$jail_dir" write_sshd_jail 2>&1 )" if [ -f "$jail_dir/sshd-serverkit.conf" ] \ && grep -q '^\[sshd\]' "$jail_dir/sshd-serverkit.conf" \ && grep -q '^enabled = true' "$jail_dir/sshd-serverkit.conf" \ && grep -q '^port = 2222' "$jail_dir/sshd-serverkit.conf"; then ok "write_sshd_jail enables the sshd jail on the detected SSH port" else bad "write_sshd_jail wrote the wrong jail: [$(cat "$jail_dir/sshd-serverkit.conf" 2>/dev/null | tr '\n' ' ')] [$out]" fi # Re-running must not append or duplicate — the file is regenerated whole. ( set -Eeuo pipefail; PATH="$t42/bin:$PATH" SSHD_CONFIG_ROOT="$t42/ssh"; FAIL2BAN_JAIL_DIR="$jail_dir" write_sshd_jail >/dev/null 2>&1 ) if [ "$(grep -c '^\[sshd\]' "$jail_dir/sshd-serverkit.conf")" = "1" ]; then ok "write_sshd_jail is idempotent across re-runs" else bad "write_sshd_jail duplicated the jail stanza on a re-run" fi # -- static guard: the interlock must stay wired. bootstrap_firewall has to call # detect_ssh_ports, and the `ufw --force enable` line must live after it. if awk '/^bootstrap_firewall\(\)/,/^}/' "$INSTALL_SH" | grep -q 'detect_ssh_ports'; then ok "bootstrap_firewall still consults detect_ssh_ports before enabling anything" else bad "bootstrap_firewall no longer detects the SSH port — LOCKOUT RISK" fi # -------------------------------------------------------------------------- # T43 — plan 71: running behind an operator's own reverse proxy. # # Issue #96. Two switches, and the failure modes are silent ones: a panel that # still 301s to HTTPS traps the proxy in a redirect loop, and a missing # SERVERKIT_PUBLIC_URL leaves the UI loading but never live-updating because # every websocket handshake is rejected on origin. # -------------------------------------------------------------------------- printf '\nT43 — external reverse proxy (SERVERKIT_EXTERNAL_PROXY / SERVERKIT_CONFIG)\n' t43="$WORK/t43" mkdir -p "$t43" # -- EXTERNAL_PROXY implies SKIP_SSL. Sourcing install.sh re-runs the settings # block, so the implication is observable without running an install. out="$( set -Eeuo pipefail SERVERKIT_EXTERNAL_PROXY=1 unset SERVERKIT_SKIP_SSL || true source "$INSTALL_SH" >/dev/null 2>&1 printf '%s' "$SERVERKIT_SKIP_SSL" )" if [ "$out" = "1" ]; then ok "SERVERKIT_EXTERNAL_PROXY=1 implies SERVERKIT_SKIP_SSL=1 (no HTTPS redirect to loop on)" else bad "SERVERKIT_EXTERNAL_PROXY=1 left SERVERKIT_SKIP_SSL=[$out] — the panel would still 301 to HTTPS" fi # -- and it must NOT flip SKIP_SSL for an ordinary install. out="$( set -Eeuo pipefail unset SERVERKIT_EXTERNAL_PROXY SERVERKIT_SKIP_SSL || true source "$INSTALL_SH" >/dev/null 2>&1 printf '%s' "$SERVERKIT_SKIP_SSL" )" if [ "$out" = "0" ]; then ok "the default install is untouched (SERVERKIT_SKIP_SSL=0)" else bad "an ordinary install now skips SSL (SERVERKIT_SKIP_SSL=[$out])" fi # -- a trailing slash on the public URL must be stripped: a browser's Origin # header never carries one, so storing it raw would never match. out="$( set -Eeuo pipefail SERVERKIT_EXTERNAL_PROXY=1 SERVERKIT_PUBLIC_URL="https://panel.example.com/" source "$INSTALL_SH" >/dev/null 2>&1 printf '%s' "$PANEL_PUBLIC_URL" )" if [ "$out" = "https://panel.example.com" ]; then ok "a trailing slash is stripped from SERVERKIT_PUBLIC_URL" else bad "SERVERKIT_PUBLIC_URL normalised to [$out]" fi # -- SERVERKIT_CONFIG supplies defaults... cat > "$t43/install.conf" <<'CONF' # ServerKit install config SERVERKIT_EXTERNAL_PROXY=1 SERVERKIT_PUBLIC_URL="https://from-file.example.com" SERVERKIT_PROFILE=minimal CONF out="$( set -Eeuo pipefail unset SERVERKIT_EXTERNAL_PROXY SERVERKIT_PUBLIC_URL SERVERKIT_PROFILE || true SERVERKIT_CONFIG="$t43/install.conf" source "$INSTALL_SH" >/dev/null 2>&1 printf '%s|%s|%s' "$SERVERKIT_EXTERNAL_PROXY" "$PANEL_PUBLIC_URL" "$PROFILE" )" if [ "$out" = "1|https://from-file.example.com|minimal" ]; then ok "SERVERKIT_CONFIG supplies defaults (quotes stripped, comments ignored)" else bad "SERVERKIT_CONFIG produced [$out]" fi # -- ...but explicit environment always wins over the file. out="$( set -Eeuo pipefail unset SERVERKIT_EXTERNAL_PROXY || true SERVERKIT_CONFIG="$t43/install.conf" SERVERKIT_PUBLIC_URL="https://from-env.example.com" SERVERKIT_PROFILE=full source "$INSTALL_SH" >/dev/null 2>&1 printf '%s|%s' "$PANEL_PUBLIC_URL" "$PROFILE" )" if [ "$out" = "https://from-env.example.com|full" ]; then ok "explicit environment overrides SERVERKIT_CONFIG" else bad "SERVERKIT_CONFIG overrode the explicit environment: [$out]" fi # -- a missing config file is a hard error, not a silently-default install. if ( set -Eeuo pipefail SERVERKIT_CONFIG="$t43/nope.conf" source "$INSTALL_SH" >/dev/null 2>&1 ) ; then bad "a missing SERVERKIT_CONFIG file was ignored" else ok "a missing SERVERKIT_CONFIG file aborts instead of installing the wrong shape" fi # -- the file must be parsed, not executed: a command in it must not run. printf 'touch %s/pwned\nSERVERKIT_PROFILE=minimal\n' "$t43" > "$t43/evil.conf" ( set -Eeuo pipefail unset SERVERKIT_PROFILE || true SERVERKIT_CONFIG="$t43/evil.conf" source "$INSTALL_SH" >/dev/null 2>&1 ) || true if [ -e "$t43/pwned" ]; then bad "SERVERKIT_CONFIG executed a command from the file — it must be parsed, not sourced" else ok "SERVERKIT_CONFIG is parsed, not sourced (a stray command does not run as root)" fi # -- write_config on an EXISTING .env must apply the proxy settings, because # "convert my install to sit behind Caddy" is a re-run and lands there. env_dir="$t43/opt" mkdir -p "$env_dir" cat > "$env_dir/.env" <<'ENVEOF' SECRET_KEY=keep-me SERVERKIT_SSL_MODE=secure # TRUST_PROXY_HEADERS=false ENVEOF ( set -Eeuo pipefail source "$INSTALL_SH" >/dev/null 2>&1 INSTALL_DIR="$env_dir"; SSL_MODE=insecure; PROFILE=standard SERVERKIT_EXTERNAL_PROXY=1; PANEL_PUBLIC_URL="https://panel.example.com" write_config >/dev/null 2>&1 ) if grep -q '^TRUST_PROXY_HEADERS=true' "$env_dir/.env" \ && grep -q '^TRUSTED_PROXY_HOPS=2' "$env_dir/.env" \ && grep -q '^SERVERKIT_PUBLIC_URL=https://panel.example.com$' "$env_dir/.env" \ && grep -q '^SECRET_KEY=keep-me' "$env_dir/.env" \ && [ "$(grep -c 'TRUST_PROXY_HEADERS' "$env_dir/.env")" = "1" ]; then ok "converting an existing install rewrites the proxy keys and keeps the secrets" else bad "existing-.env conversion produced: [$(tr '\n' ' ' < "$env_dir/.env")]" fi # -- and SERVERKIT_SSL_MODE must flip to insecure in the .env, not only in # /etc/serverkit/ssl-mode. _resolve_ssl_mode() reads the ENV VAR FIRST and # only falls back to the file, and run.py load_dotenv()s this file — so a # stale SERVERKIT_SSL_MODE=secure here keeps HSTS on and tells browsers to # force HTTPS on a port that now only speaks plain HTTP. if grep -q '^SERVERKIT_SSL_MODE=insecure' "$env_dir/.env" \ && ! grep -q '^SERVERKIT_SSL_MODE=secure' "$env_dir/.env"; then ok "the converted .env records ssl-mode=insecure so HSTS stops being sent" else bad "SERVERKIT_SSL_MODE was not flipped to insecure: [$(grep SERVERKIT_SSL_MODE "$env_dir/.env" | tr '\n' ' ')]" fi # -- an ordinary re-run must not strip proxy settings someone configured by hand. ( set -Eeuo pipefail source "$INSTALL_SH" >/dev/null 2>&1 INSTALL_DIR="$env_dir"; SSL_MODE=insecure; PROFILE=standard SERVERKIT_EXTERNAL_PROXY=0 write_config >/dev/null 2>&1 ) if grep -q '^TRUST_PROXY_HEADERS=true' "$env_dir/.env"; then ok "a plain re-run leaves existing proxy settings alone" else bad "a plain re-run removed TRUST_PROXY_HEADERS from an existing .env" fi # -- only ONE default server may exist on :80. # # Both site configs declare `listen 80 default_server` and then include # /etc/nginx/serverkit-conf.d/*.conf. install.sh writes the canonical-domain # redirect into that directory whenever PANEL_DOMAIN is set, and it used to # declare `listen 80 default_server` as well — which nginx rejects outright # ("a duplicate default server for 0.0.0.0:80"), in BOTH ssl modes. Since # launch_services starts nginx after write_config, every first install with a # domain produced an nginx that would not start. Verified against real nginx. # Directives only — the block's own comments explain this trap by naming it. canon_block="$(awk '/canonical-domain.conf </dev/null 2>&1 _t44_stub_keygen rm -rf "$t44/a"; mkdir -p "$t44/a" INSTALL_DIR="$t44/a"; SSL_MODE=insecure; PROFILE=standard write_config >/dev/null 2>&1 cat "$t44/a/.env" )" if printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=true' \ && printf '%s' "$out" | grep -q '^TRUSTED_PROXY_HOPS=1'; then ok "a stock install trusts its own nginx and derives the real client IP (1 hop)" else bad "stock install produced: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]" fi # -- exposing the raw backend port must turn it OFF: X-Forwarded-For is then # client-controlled, and a forged IP defeats the very throttles it feeds. out="$( set -Eeuo pipefail unset SERVERKIT_EXTERNAL_PROXY || true SERVERKIT_BIND_HOST=0.0.0.0 source "$INSTALL_SH" >/dev/null 2>&1 _t44_stub_keygen rm -rf "$t44/b"; mkdir -p "$t44/b" INSTALL_DIR="$t44/b"; SSL_MODE=insecure; PROFILE=standard write_config >/dev/null 2>&1 cat "$t44/b/.env" )" if printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=false' \ && ! printf '%s' "$out" | grep -q '^TRUST_PROXY_HEADERS=true'; then ok "SERVERKIT_BIND_HOST=0.0.0.0 leaves trust OFF (the header would be forgeable)" else bad "an exposed backend port still trusted X-Forwarded-For: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]" fi # -- the external-proxy switch still wins, with its own hop count. out="$( set -Eeuo pipefail SERVERKIT_EXTERNAL_PROXY=1 source "$INSTALL_SH" >/dev/null 2>&1 _t44_stub_keygen rm -rf "$t44/c"; mkdir -p "$t44/c" INSTALL_DIR="$t44/c"; SSL_MODE=insecure; PROFILE=standard write_config >/dev/null 2>&1 cat "$t44/c/.env" )" if printf '%s' "$out" | grep -q '^TRUSTED_PROXY_HOPS=2'; then ok "SERVERKIT_EXTERNAL_PROXY still overrides the default with 2 hops" else bad "external proxy did not set 2 hops: [$(printf '%s' "$out" | grep -i proxy | tr '\n' ' ')]" fi # -- backfill on a re-run: pre-existing installs are the ones actually suffering. mkdir -p "$t44/old" printf 'SECRET_KEY=keep-me\nSERVERKIT_SSL_MODE=insecure\n' > "$t44/old/.env" ( set -Eeuo pipefail unset SERVERKIT_BIND_HOST SERVERKIT_EXTERNAL_PROXY || true source "$INSTALL_SH" >/dev/null 2>&1 INSTALL_DIR="$t44/old"; SSL_MODE=insecure; PROFILE=standard write_config >/dev/null 2>&1 ) if grep -q '^TRUST_PROXY_HEADERS=true' "$t44/old/.env" \ && grep -q '^TRUSTED_PROXY_HOPS=1' "$t44/old/.env" \ && grep -q '^SECRET_KEY=keep-me' "$t44/old/.env"; then ok "a re-run backfills the trust setting into an install that predates it" else bad "re-run backfill produced: [$(tr '\n' ' ' < "$t44/old/.env")]" fi # -- but an explicit operator choice is never overridden. Someone who turned # trust off did it for a reason, and a re-run flipping it back on silently # would be the worst possible surprise. mkdir -p "$t44/explicit" printf 'SECRET_KEY=keep-me\nTRUST_PROXY_HEADERS=false\n' > "$t44/explicit/.env" ( set -Eeuo pipefail unset SERVERKIT_BIND_HOST SERVERKIT_EXTERNAL_PROXY || true source "$INSTALL_SH" >/dev/null 2>&1 INSTALL_DIR="$t44/explicit"; SSL_MODE=insecure; PROFILE=standard write_config >/dev/null 2>&1 ) if grep -q '^TRUST_PROXY_HEADERS=false' "$t44/explicit/.env" \ && ! grep -q '^TRUST_PROXY_HEADERS=true' "$t44/explicit/.env"; then ok "a re-run never overrides an explicit TRUST_PROXY_HEADERS=false" else bad "a re-run overrode the operator's explicit setting: [$(tr '\n' ' ' < "$t44/explicit/.env")]" fi # -- the two consumers of the bind host must agree. The systemd unit and the # trust decision reading different values is exactly how this class of bug # comes back. unit="$t44/unit.service" ( set -Eeuo pipefail unset SERVERKIT_BIND_HOST || true source "$INSTALL_SH" >/dev/null 2>&1 INSTALL_DIR="$REPO_DIR"; VENV_DIR="$REPO_DIR/venv"; LOG_DIR="$t44" render_service_unit "$unit" >/dev/null 2>&1 ) || true if grep -q -- '-b 127.0.0.1:5000' "$unit" 2>/dev/null; then ok "render_service_unit and the trust decision share one bind host (loopback)" else bad "the rendered unit does not bind loopback: [$(grep ExecStart "$unit" 2>/dev/null)]" fi # -- apt must never be able to open an interactive prompt. # # `apt-get install -y` answers apt's questions but NOT debconf's: tzdata and # friends still open a dialog and block. pkg_add captures output, so the # installer then hangs in complete silence — a real 60-minute stall on # ubuntu:22.04, found only once provisioning was exercised for real. Under # `curl | bash` it is worse: stdin is the script, so debconf can consume # installer source as its answers. apt_line="$(awk '/^pkg_add\(\)/,/^}/' "$INSTALL_SH" | grep -A3 'apt)')" if printf '%s' "$apt_line" | grep -q 'DEBIAN_FRONTEND=noninteractive'; then ok "pkg_add runs apt with DEBIAN_FRONTEND=noninteractive" else bad "pkg_add's apt branch can block on a debconf prompt: [$apt_line]" fi # Issue #148: reusing an interpreter must still provision matching headers. if ( set -Eeuo pipefail OS_FAMILY=debian; ID=ubuntu locate_python() { PYTHON_BIN=python3.14; return 0; } python3.14() { printf '3.14\n'; } ready=0 python_build_ready() { [ "$ready" = 1 ]; } refresh_pkg_index() { :; } pkg_add() { [ "$*" = 'build-essential python3.14-dev' ] || exit 1 ready=1 } provision_python >/dev/null ensure_python_build_deps >/dev/null [ "$ready" = 1 ] ); then ok "existing Python 3.14 gets matching headers and build tools" else bad "existing Python skips native build prerequisites" fi if ( set -Eeuo pipefail OS_FAMILY=debian; PYTHON_BIN=python3.14 python3.14() { printf '3.14\n'; } python_build_ready() { return 1; } refresh_pkg_index() { :; } pkg_add() { return 0; } # warn-and-continue package failure halt() { return 1; } ensure_python_build_deps >/dev/null 2>&1 ); then bad "missing headers were accepted after package installation failed" else ok "missing build prerequisites fail before pip" fi if ( set -Eeuo pipefail OS_FAMILY=debian; ID=ubuntu; installed=0 locate_python() { [ "$installed" = 1 ] && { PYTHON_BIN=python3.14; return 0; }; return 1; } refresh_pkg_index() { :; } pkg_add() { [ "$*" = 'python3 python3-venv python3-dev' ] || exit 1 installed=1 } ensure_python_build_deps() { exit 1; } add-apt-repository() { exit 1; } provision_python >/dev/null [ "$installed" = 1 ] ); then ok "Ubuntu uses its supported default Python without a PPA" else bad "Ubuntu did not use its default Python first" fi if ( set -Eeuo pipefail python_build_ready() { return 0; } pkg_add() { exit 1; } refresh_pkg_index() { exit 1; } ensure_python_build_deps ); then ok "working native build environment needs no package changes" else bad "working native build environment tried to install packages" fi # A source install, absent release venv, or incompatible release interpreter # must all check native prerequisites before creating a venv or invoking pip. for build_case in source missing-release broken-release; do if ( set -Eeuo pipefail build_dir="$WORK/native-$build_case" mkdir -p "$build_dir/slot" FIRST_SLOT="$build_dir/slot"; VENV_DIR="$FIRST_SLOT/venv" INSTALL_FROM_RELEASE=1 [ "$build_case" != source ] || INSTALL_FROM_RELEASE=0 if [ "$build_case" = broken-release ]; then mkdir -p "$VENV_DIR/bin" : > "$VENV_DIR/bin/activate" printf '#!/bin/sh\nexit 1\n' > "$VENV_DIR/bin/python" chmod +x "$VENV_DIR/bin/python" fi ready=0; pip_called=0 ensure_python_build_deps() { ready=1; } PYTHON_BIN=test_build_python test_build_python() { [ "$ready" = 1 ] || exit 1 mkdir -p "$VENV_DIR/bin" : > "$VENV_DIR/bin/activate" } pip() { [ "$ready" = 1 ] || exit 1; pip_called=1; } build_virtualenv >/dev/null 2>&1 [ "$ready" = 1 ] && [ "$pip_called" = 1 ] ); then ok "$build_case checks build prerequisites before pip" else bad "$build_case skipped build prerequisites" fi done if ( set -Eeuo pipefail INSTALL_FROM_RELEASE=0 ensure_python_build_deps() { return 1; } PYTHON_BIN=never_build_python never_build_python() { exit 99; } rc=0 build_virtualenv >/dev/null 2>&1 || rc=$? [ "$rc" = 1 ] ); then ok "failed prerequisites stop the local build before venv creation" else bad "local build ignored prerequisite failure" fi # -- static guard: the documented insecure site must keep shipping, since both # the docs and SERVERKIT_EXTERNAL_PROXY point operators straight at it. if [ -f "$REPO_DIR/nginx/sites-available/serverkit-insecure.conf" ] \ && ! grep -qE 'return[[:space:]]+30[12]' "$REPO_DIR/nginx/sites-available/serverkit-insecure.conf"; then ok "serverkit-insecure.conf ships and contains no redirect for a proxy to loop on" else bad "serverkit-insecure.conf is missing or now redirects — issue #96 would recur" fi # -------------------------------------------------------------------------- printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP" [ "$FAIL" -eq 0 ]