From 1c077a15d0eecb520f5f7f63a795a98502e7cb88 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 19:04:23 +0200 Subject: [PATCH] fix(station): RuntimeDirectory-Race, Statusphase, off/ap_switch-Nachpruefung, Pi-Bestandskonfiguration Fixrunde 1 zu Task 4: - C1 (kritisch): die Unit leerte /run/ninlight-station bei jedem systemctl restart, weil RuntimeDirectoryPreserve fehlte - genau der Neustart, mit dem migrate/recovery on den Dienst hochfahren, wischte die eben geschriebene Modus-/Steuerdatei wieder weg, und der Server fiel still auf den Recovery-Default zurueck. Unit bekommt RuntimeDirectoryPreserve=restart, und start_and_verify() prueft nach dem Neustart zusaetzlich ausdruecklich nach, dass Modus und Phase noch stimmen. - I1: status las die Phase aus status.json, die arm/listen (die den Dienst bewusst nicht neu starten) nie aktualisieren - jetzt kommt sie aus control.json. - I2: off pruefte nach dem Herunterfahren nichts nach und konnte "Station aus" melden, waehrend ein Profil wegen haengendem NetworkManager weiterhin aktiv war - ap_switch() verifiziert das jetzt auch im Aus-Fall. - I3: der Fehlerzweig von ap_switch (Mismatch nach dem Umschalten) war im bisherigen Test nie erreichbar; der nmcli-Stub kann das jetzt erzwingen. - I4: neue Pflichtschluessel blockierten jeden Unterbefehl auf einem bereits eingerichteten Pi. setup_pi.sh ergaenzt fehlende Schluessel in einer bestehenden Konfiguration jetzt einzeln, statt sie unangetastet zu lassen; dafuer musste das Skript per bash-s-Heredocs und ueberschreibbaren Pfadvariablen erst testbar gemacht werden (neuer test_setup_pi.sh). Dazu vier kleinere Punkte aus derselben Review: grep -Fqx statt grep -qx fuer Profilnamen, install-Stub durch tee+chmod ersetzt (POSIX, kein Sonderfall mehr fuer macOS), Rechtepruefung auf control.json in den arm/listen-Testfaellen, stderr sauber von stdout getrennt in den Warnungs-/Fehlerfaellen. --- tools/station/ninlight-station | 136 ++++++++++++++--- tools/station/ninlight-station.service | 12 ++ tools/station/setup_pi.sh | 101 +++++++++---- tools/station/tests/test_cli.sh | 194 ++++++++++++++++++------ tools/station/tests/test_setup_pi.sh | 196 +++++++++++++++++++++++++ tools/test_host.sh | 1 + 6 files changed, 550 insertions(+), 90 deletions(-) create mode 100755 tools/station/tests/test_setup_pi.sh diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 49f36fc..53c89c5 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -47,7 +47,9 @@ unset _key need_profile() { # $1 = NetworkManager-Profilname, der existieren muss. - nmcli -t -f NAME con show | grep -qx "$1" || { + # -F: der Profilname ist ein Konfigurationswert, kein BRE-Muster - ein Name mit + # "." oder "[" wuerde mit "grep -x" sonst falsch (oder gar nicht) treffen. + nmcli -t -f NAME con show | grep -Fqx "$1" || { echo "Fehler: NetworkManager-Profil '$1' fehlt - tools/station/setup_pi.sh ausfuehren" >&2 exit 1 } @@ -77,7 +79,22 @@ ap_switch() { fi sudo nmcli con down "$_p" >/dev/null 2>&1 || true done - [ -n "$_target" ] || return 0 + if [ -z "$_target" ]; then + # "off": jedes "con down" oben ist mit "|| true" abgesichert, weil ein + # haengender NetworkManager den Aufruf sonst zum Abbruch braechte, bevor + # ueberhaupt versucht wurde, beide Profile herunterzufahren. Das darf aber + # nicht heissen, dass ein haengengebliebenes Profil stillschweigend als + # "aus" durchgeht: nachsehen, ob wlan0 wirklich kein Stationsprofil mehr + # zeigt, und erst dann Erfolg melden (Fixrunde 1, I2) - sonst kann z.B. der + # Migrations-AP mit oeffentlich bekanntem Passwort weitersenden, waehrend + # das CLI "Station aus" behauptet. + _active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2) + if [ "$_active" = "$AP_CONNECTION" ] || [ "$_active" = "$MIGRATE_AP_CONNECTION" ]; then + echo "Fehler: wlan0 zeigt nach dem Ausschalten immer noch '$_active' aktiv - NetworkManager pruefen (Zugangspunkt bleibt an)" >&2 + return 1 + fi + return 0 + fi sudo nmcli con up "$_target" >/dev/null _active=$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2) if [ "$_active" != "$_target" ]; then @@ -93,14 +110,14 @@ ap_switch() { write_mode() { sudo mkdir -p "$RUN_DIR" _tmp="$RUN_DIR/.mode-$$" - printf '%s' "$1" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1 + printf '%s' "$1" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$RUN_DIR/mode" } -# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei per -# "install" anlegen, dann umbenennen) und dieselben Rechte wie server.py's eigenes -# write_control(), sonst kann der Dienst die Datei anschliessend nicht mehr -# ueberschreiben oder das CLI sie nicht mehr lesen. +# Schreibt $RUN_DIR/control.json atomar mit 0644 - dasselbe Muster (Temp-Datei im +# selben Verzeichnis anlegen, Rechte setzen, dann umbenennen) und dieselben Rechte +# wie server.py's eigenes write_control(), sonst kann der Dienst die Datei +# anschliessend nicht mehr ueberschreiben oder das CLI sie nicht mehr lesen. # # $1 = phase (auto|listen|armed), $2 = armed_target oder leer (-> null im JSON). write_control() { @@ -111,13 +128,36 @@ write_control() { else _json=$(printf '{"phase": "%s", "armed_target": null}' "$1") fi - printf '%s' "$_json" | sudo install -m 0644 /dev/stdin "$_tmp" || return 1 + printf '%s' "$_json" | sudo tee "$_tmp" >/dev/null && sudo chmod 0644 "$_tmp" || return 1 sudo mv -f "$_tmp" "$RUN_DIR/control.json" } -# Startet (neu) und wartet kurz auf Lebenszeichen. $1 = das gerade hochgefahrene -# AP-Profil, das bei einem Fehlschlag wieder heruntergefahren wird. +# Startet (neu) und wartet kurz auf Lebenszeichen, dann prueft es nach, dass der +# eben gestartete Prozess wirklich den beabsichtigten Modus/die beabsichtigte +# Phase sieht. +# +# $1 = das gerade hochgefahrene AP-Profil (fuer Rollback bei Fehlschlag) +# $2 = erwarteter Inhalt von $RUN_DIR/mode nach dem Neustart +# $3 = erwartete Phase in control.json (nur im Migrationsmodus angeben, sonst leer) +# +# Der Grund fuer $2/$3: die systemd-Unit traegt RuntimeDirectory=ninlight-station, +# und systemd leert dieses Verzeichnis bei jedem Stop/Restart, sofern die Unit +# nicht RuntimeDirectoryPreserve=restart setzt. write_mode()/write_control() oben +# laufen VOR diesem "systemctl restart" - ein Neustart, der das Verzeichnis doch +# leert (weil die Unit die Zeile verliert, oder auf einem Pi mit alter Unit-Datei +# installiert wird), wischt also genau die Dateien wieder weg, die gerade erst +# geschrieben wurden. Der frisch gestartete Serverprozess faende dann keine +# Modusdatei und faellt laut server.py:resolve_mode() still auf "recovery" +# zurueck (bzw. read_control() auf "auto" bei fehlender Steuerdatei) - ein +# ausdrueckliches "migrate on --listen" wuerde so unbemerkt zu "Rettungsmodus, +# antwortet 404" bzw. zu "automatische Auslieferung" (Fixrunde 1, C1). Die Unit +# traegt deshalb RuntimeDirectoryPreserve=restart; diese Nachpruefung faengt es +# zusaetzlich ab, falls das je auseinanderlaeuft, statt sich allein auf die +# Unit-Datei zu verlassen. start_and_verify() { + _ap=$1 + _expect_mode=$2 + _expect_phase=${3:-} # Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht # blockieren (systemd verweigert "start"/"restart" sonst sofort mit # "start-limit-hit"). @@ -127,7 +167,7 @@ start_and_verify() { # set -eu wuerde das Skript hier sonst abbrechen, bevor die # Lebendigkeitspruefung unten je erreicht wird, und der eben # hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen. - sudo nmcli con down "$1" >/dev/null 2>&1 || true + sudo nmcli con down "$_ap" >/dev/null 2>&1 || true echo "Fehler: ninlight-station liess sich nicht (neu) starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 exit 1 fi @@ -140,10 +180,37 @@ start_and_verify() { # weiter in seiner Neustartschleife - erst stoppen, dann den AP runter, # sonst suggeriert die Meldung unten einen saubereren Zustand als er ist. sudo systemctl stop ninlight-station 2>/dev/null || true - sudo nmcli con down "$1" >/dev/null 2>&1 || true + sudo nmcli con down "$_ap" >/dev/null 2>&1 || true echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2 exit 1 fi + _actual_mode=$(cat "$RUN_DIR/mode" 2>/dev/null || true) + if [ "$_actual_mode" != "$_expect_mode" ]; then + sudo systemctl stop ninlight-station 2>/dev/null || true + sudo nmcli con down "$_ap" >/dev/null 2>&1 || true + echo "Fehler: $RUN_DIR/mode enthaelt nach dem Neustart '$_actual_mode' statt '$_expect_mode' - RuntimeDirectory wurde vermutlich geleert (RuntimeDirectoryPreserve=restart in der Unit pruefen). Zugangspunkt wieder abgeschaltet." >&2 + exit 1 + fi + if [ -n "$_expect_phase" ]; then + _actual_phase=$(python3 - "$RUN_DIR/control.json" <<'PY' +import json +import sys + +try: + with open(sys.argv[1]) as f: + d = json.load(f) +except (OSError, ValueError): + d = {} +print(d.get("phase", "")) +PY +) + if [ "$_actual_phase" != "$_expect_phase" ]; then + sudo systemctl stop ninlight-station 2>/dev/null || true + sudo nmcli con down "$_ap" >/dev/null 2>&1 || true + echo "Fehler: $RUN_DIR/control.json enthaelt nach dem Neustart Phase '$_actual_phase' statt '$_expect_phase' - RuntimeDirectory wurde vermutlich geleert. Zugangspunkt wieder abgeschaltet." >&2 + exit 1 + fi + fi } recovery_on() { @@ -152,7 +219,7 @@ recovery_on() { [ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2 ap_switch "$AP_CONNECTION" write_mode "recovery" - start_and_verify "$AP_CONNECTION" + start_and_verify "$AP_CONNECTION" "recovery" echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)" # Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in # Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist @@ -184,7 +251,7 @@ migrate_on() { echo "Fehler: $RUN_DIR/control.json konnte nicht geschrieben werden" >&2 exit 1 } - start_and_verify "$MIGRATE_AP_CONNECTION" + start_and_verify "$MIGRATE_AP_CONNECTION" "migration" "$_phase" echo "Migration an: SSID $MIGRATE_SSID auf 192.168.8.1, Phase $_phase, $n Migrationsimage(s)" echo "Achtung: das Passwort dieses Zugangspunkts ist aus der alten Firmware oeffentlich bekannt - siehe README.md." >&2 echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist." @@ -193,7 +260,7 @@ migrate_on() { full_off() { sudo systemctl stop ninlight-station 2>/dev/null || true ap_switch "" - if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then + if nmcli -t -f NAME con show | grep -Fqx "$HOME_CONNECTION"; then if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)" else @@ -262,11 +329,13 @@ status_cmd() { # landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit # erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand. _status_file="$RUN_DIR/status.json" + _control_file="$RUN_DIR/control.json" # "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar" # sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im # "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe # server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie - # "noch nie ausgeliefert". Das gilt auch fuer Modus/Phase/Erkannt unten. + # "noch nie ausgeliefert". Das gilt auch fuer Modus/Erkannt unten und fuer + # Phase/control.json weiter unten. if [ -r "$_status_file" ]; then _info=$(python3 - "$_status_file" <<'PY' import json @@ -286,22 +355,47 @@ if last_seen: else: seen = "-" print(d.get("mode", "-")) -print(d.get("phase", "-")) print(seen) PY ) _modus=$(printf '%s\n' "$_info" | sed -n '1p') - _phase=$(printf '%s\n' "$_info" | sed -n '2p') - _erkannt=$(printf '%s\n' "$_info" | sed -n '3p') + _erkannt=$(printf '%s\n' "$_info" | sed -n '2p') elif [ -e "$_status_file" ]; then _modus="unbekannt (Datei nicht lesbar)" - _phase="unbekannt (Datei nicht lesbar)" _erkannt="unbekannt (Datei nicht lesbar)" else _modus="-" - _phase="-" _erkannt="-" fi + # Die Phase kommt bewusst aus control.json, nicht aus status.json: "arm" und + # "listen" starten den Dienst absichtlich nicht neu, also schreibt server.py's + # write_state() status.json in der Zwischenzeit nicht neu (Fixrunde 1, I1) - + # wer die Phase dort ablesen wuerde, saehe nach einer erfolgreichen Freigabe + # weiterhin die Phase von vor dem letzten Dienststart und koennte "arm" fuer + # gescheitert halten oder faelschlich glauben, nichts sei mehr scharf. + if [ -r "$_control_file" ]; then + _phase=$(python3 - "$_control_file" <<'PY' +import json +import sys + +try: + with open(sys.argv[1]) as f: + d = json.load(f) +except (OSError, ValueError): + d = {} +phase = d.get("phase", "-") +target = d.get("armed_target") +if phase == "armed" and target: + print("{} (Ziel: {})".format(phase, target)) +else: + print(phase) +PY +) + elif [ -e "$_control_file" ]; then + _phase="unbekannt (Datei nicht lesbar)" + else + _phase="-" + fi printf 'Modus : %s\n' "$_modus" printf 'Phase : %s\n' "$_phase" printf 'Erkannt: %s\n' "$_erkannt" diff --git a/tools/station/ninlight-station.service b/tools/station/ninlight-station.service index e464ee9..bda200e 100644 --- a/tools/station/ninlight-station.service +++ b/tools/station/ninlight-station.service @@ -32,6 +32,18 @@ ProtectSystem=strict ProtectHome=yes PrivateTmp=yes RuntimeDirectory=ninlight-station +# Ohne dies leert systemd /run/ninlight-station bei JEDEM Stop und Neustart, nicht +# nur beim allerersten Start - und ninlight-station (das CLI) schreibt mode/ +# control.json immer VOR dem "systemctl restart", mit dem migrate/recovery on den +# Dienst (neu) starten. Ohne RuntimeDirectoryPreserve=restart wischt genau dieser +# Neustart die beiden gerade geschriebenen Dateien wieder weg, bevor der neue +# Serverprozess sie lesen kann - der Dienst faellt dann still auf den +# Recovery-Default zurueck (server.py resolve_mode()/read_control()), und ein +# ausdrueckliches "migrate on --listen" wird zu "Rettungsmodus" bzw. "auto" +# (Fixrunde 1, C1). "restart" (nicht "yes") reicht: das Verzeichnis muss nur einen +# Neustart ueberleben, nicht dauerhaft bestehen bleiben, wenn der Dienst regulaer +# gestoppt wird. +RuntimeDirectoryPreserve=restart ReadWritePaths=/run/ninlight-station RestrictAddressFamilies=AF_INET AF_UNIX MemoryMax=128M diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh index bdf72d9..02be11e 100755 --- a/tools/station/setup_pi.sh +++ b/tools/station/setup_pi.sh @@ -1,45 +1,87 @@ #!/usr/bin/env bash -# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent: -# mehrfaches Ausfuehren aendert nichts und schadet nicht. +# Richtet einen Raspberry Pi als ninlight-Station ein. Idempotent: mehrfaches +# Ausfuehren aendert nichts und schadet nicht - auch nicht auf einem Pi, auf dem +# schon eine aeltere Version dieses Skripts gelaufen ist (siehe "Konfiguration" +# unten: fehlende Schluessel werden ergaenzt, vorhandene bleiben unangetastet). # # Aufruf: tools/station/setup_pi.sh set -euo pipefail HOST=${1:?Aufruf: tools/station/setup_pi.sh } HERE=$(cd "$(dirname "$0")" && pwd) # /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen. -# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt -# einen echten Pi laufen kann. +# Ueberschreibbar (wie NINLIGHT_STATION_ROOT unten und in deploy.sh), damit ein Test gegen +# Stubs statt einen echten Pi laufen kann. SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh} SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp} echo "== Voraussetzungen auf $HOST pruefen ==" -"$SSH_BIN" "$HOST" 'set -e - systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; } - nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; } - python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; } - echo " ok"' +"$SSH_BIN" "$HOST" bash -s <<'REMOTE' +set -e +systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; } +nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; } +python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; } +echo " ok" +REMOTE echo "== Konfiguration ==" "$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example" -"$SSH_BIN" "$HOST" 'set -e - if [ -f /etc/ninlight-station.conf ]; then - echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert" +# CONF ueberschreibbar wie ROOT_DIR/BIN_PATH/UNIT_PATH unten: auf dem Pi bleibt es +# beim Default, ein Test kann hier gegen eine Sandbox-Datei statt gegen echte +# Systempfade laufen. +"$SSH_BIN" "$HOST" bash -s <<'REMOTE' +set -e +CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} +TEMPLATE=/tmp/ninlight-station.conf.example +if [ -f "$CONF" ]; then + # Eine bestehende Konfiguration bleibt bei ihren Werten - nur Schluessel, die + # eine neuere Version des CLI zusaetzlich verlangt (z.B. die Migrations- und + # Anzeige-Schluessel aus Task 4), werden aus der Vorlage ergaenzt. Ohne das + # wuerde das Ausrollen eines neuen ninlight-station auf einen bereits + # eingerichteten Pi jeden Unterbefehl - "off" eingeschlossen - mit "Schluessel + # ... fehlt" blockieren, und ein bereits laufender Zugangspunkt bliebe an, bis + # jemand die 0600-Datei von Hand ergaenzt (Fixrunde 1, I4). + ADDED="" + for KEY in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION \ + MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do + if ! sudo grep -Eq "^${KEY}=" "$CONF"; then + LINE=$(grep -E "^${KEY}=" "$TEMPLATE" || true) + if [ -n "$LINE" ]; then + printf '%s\n' "$LINE" | sudo tee -a "$CONF" >/dev/null + ADDED="$ADDED $KEY" + fi + fi + done + if [ -n "$ADDED" ]; then + echo " $CONF: fehlende Schluessel ergaenzt:$ADDED (vorhandene Werte unveraendert)" else - sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf - echo " /etc/ninlight-station.conf aus der Vorlage angelegt" + echo " $CONF existiert bereits, alle Schluessel vorhanden" fi - rm -f /tmp/ninlight-station.conf.example' +else + sudo install -m 0600 "$TEMPLATE" "$CONF" + echo " $CONF aus der Vorlage angelegt" +fi +rm -f "$TEMPLATE" +REMOTE echo "== Server, CLI und Unit installieren ==" -"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" -"$SSH_BIN" "$HOST" 'set -e - sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images - sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py - sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station - sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service - rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service - sudo systemctl daemon-reload - echo " installiert"' +"$SCP_BIN" -q "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" +# Pfade als Variablen mit Default (wie in deploy.sh): so kann ein Test die +# Installation gegen ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen +# lassen. +"$SSH_BIN" "$HOST" bash -s <<'REMOTE' +set -e +ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} +BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} +UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} +sudo install -d -m 0755 "$ROOT_DIR" "$ROOT_DIR/images" +sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" +sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py" +sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH" +sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" +rm -f /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service +sudo systemctl daemon-reload +echo " installiert" +REMOTE echo "== NetworkManager-Profil ==" # Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf: @@ -56,13 +98,18 @@ echo "== NetworkManager-Profil ==" # Prozessliste. "$SSH_BIN" "$HOST" bash -s <<'REMOTE' set -e -eval "$(sudo cat /etc/ninlight-station.conf)" -CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection" +CONF=${NINLIGHT_STATION_CONF:-/etc/ninlight-station.conf} +NM_DIR=${NINLIGHT_NM_DIR:-/etc/NetworkManager/system-connections} +eval "$(sudo cat "$CONF")" +CONN_FILE="$NM_DIR/${AP_CONNECTION}.nmconnection" if sudo test -f "$CONN_FILE"; then UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1) ACTION="aktualisiert" else - UUID=$(cat /proc/sys/kernel/random/uuid) + # /proc/sys/kernel/random/uuid gibt es nur unter Linux (dem Pi) - der Fallback + # auf uuidgen ist es, was einen Testlauf gegen den lokalen Stub (macOS) ueberhaupt + # ermoeglicht, aendert aber nichts am Verhalten auf dem Pi selbst. + UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen | tr '[:upper:]' '[:lower:]') ACTION="angelegt" fi TMP=$(mktemp) diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index deba20f..dde339e 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -7,7 +7,11 @@ # Der nmcli-Stub haelt zusaetzlich einen kleinen Zustand (welche Profile # "bekannt" sind, welche gerade per "con up" aktiv sind) in Dateien, damit # "con up"/"con down"/"device show wlan0" konsistent zueinander antworten - -# das ist die Grundlage fuer Fall 15, den wichtigsten Test hier. +# das ist die Grundlage fuer Fall 15, den wichtigsten Test hier. Der +# systemctl-Stub bildet zusaetzlich nach, dass systemd das RuntimeDirectory bei +# jedem Stop/Restart leert, sofern die Unit kein RuntimeDirectoryPreserve=restart +# traegt - ohne das haette Fixrunde 1's C1 (siehe unten) unsichtbar bleiben +# koennen, weil ein Stub, der nichts loescht, diese Klasse von Bug nicht zeigt. # # Aufruf: tools/station/tests/test_cli.sh set -euo pipefail @@ -15,6 +19,7 @@ set -euo pipefail HERE=$(cd "$(dirname "$0")" && pwd) STATION_DIR=$(cd "$HERE/.." && pwd) CLI="$STATION_DIR/ninlight-station" +REAL_UNIT="$STATION_DIR/ninlight-station.service" TMPROOT=$(mktemp -d) trap 'rm -rf "$TMPROOT"' EXIT @@ -23,10 +28,18 @@ FAILED=0 pass() { echo "ok - $1"; } fail() { echo "FEHLER - $1" >&2; FAILED=1; } +file_mode() { + # $1 = Pfad. BSD-stat (macOS) und GNU-stat (Linux/Pi) haben unterschiedliche + # Flags - hier reicht "irgendeine funktioniert". + stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" 2>/dev/null +} + # --- Stub-Kommandos fuer nmcli/systemctl/sudo im PATH --- -# $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis +# $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis, $4 = RUN_DIR, +# $5 = Pfad zur systemd-Unit, deren RuntimeDirectoryPreserve-Zeile ueber das +# Leeren von $4 bei restart/stop entscheidet (wie beim echten systemd). setup_stubbin() { - local bindir=$1 log=$2 nmstate=$3 + local bindir=$1 log=$2 nmstate=$3 rundir=$4 unitfile=$5 mkdir -p "$bindir" "$nmstate" : >"$nmstate/active" # Reihenfolge der per "con up" aktiven Profile, eine Zeile je Profil @@ -41,6 +54,12 @@ if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "NAME" ] && [ "\$4" = "con" fi if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "GENERAL.CONNECTION" ] && [ "\$4" = "device" ] && [ "\$5" = "show" ]; then + # Testschalter fuer Fall I3 (Fixrunde 1): erzwingt eine Antwort, die nicht zum + # zuletzt hochgefahrenen Profil passt, um ap_switch()'s Fehlerzweig zu pruefen. + if [ -n "\${FAKE_NMCLI_DEVSHOW:-}" ]; then + echo "GENERAL.CONNECTION:\${FAKE_NMCLI_DEVSHOW}" + exit 0 + fi last=\$(tail -n1 "\$NMSTATE/active" 2>/dev/null) echo "GENERAL.CONNECTION:\${last:---}" exit 0 @@ -54,6 +73,13 @@ if [ "\$1" = "con" ] && [ "\$2" = "up" ]; then fi if [ "\$1" = "con" ] && [ "\$2" = "down" ]; then + # Testschalter fuer Fall I2 (Fixrunde 1): simuliert einen haengenden + # NetworkManager - der Aufruf "gelingt" (Exit 0, wie ein echtes "nmcli con + # down" es bei einem haengenden Backend oft tut), aendert den Zustand aber + # nicht. ap_switch() darf das nicht als "aus" durchgehen lassen. + if [ -n "\${FAKE_NMCLI_STUCK:-}" ]; then + exit 0 + fi profile=\$3 grep -vxF "\$profile" "\$NMSTATE/active" 2>/dev/null >"\$NMSTATE/active.tmp" || true mv "\$NMSTATE/active.tmp" "\$NMSTATE/active" @@ -66,6 +92,8 @@ EOF cat >"$bindir/systemctl" <>"$log" +RUN_DIR="$rundir" +UNIT_FILE="$unitfile" case "\$1" in is-active) if [ "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" -eq 0 ]; then @@ -76,7 +104,20 @@ case "\$1" in exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}" fi ;; - restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;; + restart|stop) + # Bildet systemds RuntimeDirectory-Verhalten nach (Fixrunde 1, C1): ohne + # RuntimeDirectoryPreserve=restart in der Unit leert systemd das + # RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start, + # und legt es leer wieder an. + if ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then + rm -rf "\$RUN_DIR" + mkdir -p "\$RUN_DIR" + fi + if [ "\$1" = "restart" ]; then + exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" + fi + exit 0 + ;; *) exit 0 ;; esac EOF @@ -87,37 +128,14 @@ echo "STUB sudo \$*" >>"$log" exec "\$@" EOF - # BSD-"install" (macOS) kennt kein /dev/stdin als Quelle ("Inappropriate file - # type or format") - GNU coreutils auf dem Pi schon. Das Skript nutzt genau - # dieses Muster (sudo install -m 0644 /dev/stdin ), also braucht der - # Test einen kleinen Ersatz, der nur dieses eine Aufrufmuster kennt. - cat >"$bindir/install" <<'EOF' -#!/usr/bin/env bash -mode="" -args=() -while [ $# -gt 0 ]; do - case "$1" in - -m) mode=$2; shift 2 ;; - *) args+=("$1"); shift ;; - esac -done -src=${args[0]} -dest=${args[1]} -if [ "$src" = "/dev/stdin" ]; then - cat >"$dest" -else - cp "$src" "$dest" -fi -[ -n "$mode" ] && chmod "$mode" "$dest" -EOF - - chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo "$bindir"/install + chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo } # --- Sandbox fuer einen Testfall: eigene conf/RUN_DIR/IMAGE_DIR/nmcli-Zustand --- -# $1 = sandbox-Verzeichnis +# $1 = sandbox-Verzeichnis, $2 = Unit-Datei fuer den systemctl-Stub (Default: die +# echte, gerade zu testende tools/station/ninlight-station.service). setup_sandbox() { - local sandbox=$1 + local sandbox=$1 unitfile=${2:-$REAL_UNIT} rm -rf "$sandbox" mkdir -p "$sandbox/run" "$sandbox/images/migrate" "$sandbox/bin" "$sandbox/nmstate" echo "dummy" >"$sandbox/images/ninlight_foo_ota.img" @@ -137,7 +155,7 @@ EOF printf 'ninlight-recovery\nqconnex-firmware\nhome-wifi\n' >"$sandbox/nmstate/profiles" : >"$sandbox/nmstate/active" - setup_stubbin "$sandbox/bin" "$sandbox/cmd.log" "$sandbox/nmstate" + setup_stubbin "$sandbox/bin" "$sandbox/cmd.log" "$sandbox/nmstate" "$sandbox/run" "$unitfile" } # Fuehrt ninlight-station mit gestubbtem PATH und ueberschriebenen Pfaden aus. @@ -227,11 +245,12 @@ SBX="$TMPROOT/t5" setup_sandbox "$SBX" # images/migrate/ bleibt leer set +e -OUT=$(run_cli "$SBX" migrate on 2>&1) +OUT=$(run_cli "$SBX" migrate on 2>"$SBX/stderr.log") RC=$? set -e -[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, Ausgabe: $OUT" -echo "$OUT" | grep -q "Warnung" && pass "Warnung vorhanden" || fail "keine Warnung in der Ausgabe: $OUT" +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR" +echo "$ERR" | grep -q "Warnung" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR" echo echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ===" @@ -247,6 +266,7 @@ set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'" [ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb (aus last_seen)" || fail "armed_target falsch: $(cat "$SBX/run/control.json")" +[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")" echo echo "=== Fall 7: 'arm gu10_rgb' mit explizitem Ziel ===" @@ -259,30 +279,33 @@ set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'" [ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb" || fail "armed_target falsch: $(cat "$SBX/run/control.json")" +[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")" echo -echo "=== Fall 8: 'arm' ohne last_seen -> Fehler, Rueckgabewert != 0, control.json unveraendert ===" +echo "=== Fall 8: 'arm' ohne last_seen -> Fehler auf stderr, Rueckgabewert != 0, control.json unveraendert ===" SBX="$TMPROOT/t8" setup_sandbox "$SBX" # keine status.json vorhanden set +e -OUT=$(run_cli "$SBX" arm 2>&1) +OUT=$(run_cli "$SBX" arm 2>"$SBX/stderr.log") RC=$? set -e +ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" -echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT" +echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")" echo -echo "=== Fall 9: 'arm unbekannt' -> Fehler, Rueckgabewert != 0 ===" +echo "=== Fall 9: 'arm unbekannt' -> Fehler auf stderr, Rueckgabewert != 0 ===" SBX="$TMPROOT/t9" setup_sandbox "$SBX" set +e -OUT=$(run_cli "$SBX" arm unbekannt 2>&1) +OUT=$(run_cli "$SBX" arm unbekannt 2>"$SBX/stderr.log") RC=$? set -e +ERR=$(cat "$SBX/stderr.log") [ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0" -echo "$OUT" | grep -qi "fehler" && pass "Fehlermeldung vorhanden" || fail "keine Fehlermeldung: $OUT" +echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR" [ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt" echo @@ -295,6 +318,7 @@ RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)" +[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")" echo echo "=== Fall 11: 'off' faehrt beide Profile herunter, stoppt den Dienst, zurueck ins Heim-WLAN ===" @@ -318,8 +342,11 @@ echo echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ===" SBX="$TMPROOT/t12" setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on >/dev/null 2>&1 # phase=auto in control.json, wie es der echte Ablauf waere +# status.json so, wie server.py sie beim Start geschrieben haette (mode/last_seen). cat >"$SBX/run/status.json" <<'JSON' -{"mode": "migration", "phase": "auto", +{"mode": "migration", "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}} JSON set +e @@ -379,6 +406,89 @@ fi DEVSHOW=$(PATH="$SBX/bin:$PATH" nmcli -t -f GENERAL.CONNECTION device show wlan0) [ "$DEVSHOW" = "GENERAL.CONNECTION:qconnex-firmware" ] && pass "nmcli device show wlan0 bestaetigt qconnex-firmware" || fail "device show liefert: $DEVSHOW" +echo +echo "=== Zusatzfall C1: ohne RuntimeDirectoryPreserve=restart wuerde der Neustart mode/control.json wegwischen - das CLI muss das erkennen und abbrechen ===" +SBX="$TMPROOT/tC1" +BROKEN_UNIT="$TMPROOT/broken-ninlight-station.service" +grep -v '^RuntimeDirectoryPreserve=restart' "$REAL_UNIT" >"$BROKEN_UNIT" +setup_sandbox "$SBX" "$BROKEN_UNIT" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +set +e +OUT=$(run_cli "$SBX" migrate on --listen 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT" +echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR" +echo "$OUT" | grep -q "Migration an" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'" +ACTIVE_AFTER=$(cat "$SBX/nmstate/active") +[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER" +# Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei - +# jetzt darf RUN_DIR beim Neustart nicht geleert werden und der Aufruf muss +# durchlaufen. Das ist die eigentliche Bestaetigung, dass RuntimeDirectoryPreserve +# in tools/station/ninlight-station.service tatsaechlich steht und wirkt. +SBX2="$TMPROOT/tC1-fixed" +setup_sandbox "$SBX2" +echo "dummy" >"$SBX2/images/migrate/gu10_rgb_prod.img" +set +e +OUT2=$(run_cli "$SBX2" migrate on --listen 2>&1) +RC2=$? +set -e +[ "$RC2" -eq 0 ] && pass "mit der echten (reparierten) Unit-Datei gelingt derselbe Aufruf" || fail "Gegenprobe schlug fehl - Exit-Code $RC2: $OUT2" +[ "$(control_phase "$SBX2/run/control.json")" = "listen" ] && pass "Gegenprobe: control.json ueberlebt den Neustart mit phase=listen" || fail "Gegenprobe: Phase nach Neustart falsch: $(cat "$SBX2/run/control.json" 2>&1)" + +echo +echo "=== Zusatzfall I1: 'status' zeigt die Phase aus control.json, nicht die veraltete Phase aus status.json ===" +SBX="$TMPROOT/tI1" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +run_cli "$SBX" migrate on --listen >/dev/null 2>&1 +# status.json wird nur beim Dienststart/bei einer Auslieferung geschrieben - hier +# simuliert, wie es nach dem Start aussehen wuerde (Phase "listen", wie beim Start). +cat >"$SBX/run/status.json" <<'JSON' +{"mode": "migration", "phase": "listen", + "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}} +JSON +run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 # startet den Dienst NICHT neu - status.json bleibt bei "listen" +OUT=$(run_cli "$SBX" status 2>&1) +echo "$OUT" | grep -q "Phase.*armed" && pass "status zeigt 'armed' (aus control.json), nicht das veraltete 'listen' aus status.json" || fail "status zeigt die falsche/veraltete Phase: $OUT" +echo "$OUT" | grep -q "gu10_rgb" && pass "status nennt das freigegebene Ziel" || fail "status nennt das armed_target nicht: $OUT" + +echo +echo "=== Zusatzfall I2: 'off' bricht mit Fehler ab, wenn ein Stationsprofil trotz 'con down' aktiv bleibt (haengender NetworkManager) ===" +SBX="$TMPROOT/tI2" +setup_sandbox "$SBX" +run_cli "$SBX" on >/dev/null 2>&1 +# HOME_CONNECTION aus den bekannten Profilen entfernen, um zugleich den in I2 +# genannten zweiten Umstand abzudecken (Zielprofil fuers Heim-WLAN fehlt). +grep -v '^home-wifi$' "$SBX/nmstate/profiles" >"$SBX/nmstate/profiles.tmp" +mv "$SBX/nmstate/profiles.tmp" "$SBX/nmstate/profiles" +set +e +OUT=$(FAKE_NMCLI_STUCK=1 run_cli "$SBX" off 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT" +echo "$OUT" | grep -q "Station aus" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung" +echo "$ERR" | grep -qi "fehler" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR" +ACTIVE_AFTER=$(cat "$SBX/nmstate/active") +[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER" + +echo +echo "=== Zusatzfall I3: ap_switch bricht ab und faehrt beide Profile herunter, wenn 'device show wlan0' nach dem Umschalten nicht passt ===" +SBX="$TMPROOT/tI3" +setup_sandbox "$SBX" +echo "dummy" >"$SBX/images/migrate/gu10_rgb_prod.img" +set +e +OUT=$(FAKE_NMCLI_DEVSHOW=fremdes-profil run_cli "$SBX" migrate on 2>"$SBX/stderr.log") +RC=$? +set -e +ERR=$(cat "$SBX/stderr.log") +[ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT" +echo "$ERR" | grep -q "unerwartet" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR" +ACTIVE_AFTER=$(cat "$SBX/nmstate/active") +[ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER" + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden." diff --git a/tools/station/tests/test_setup_pi.sh b/tools/station/tests/test_setup_pi.sh new file mode 100755 index 0000000..663c0a0 --- /dev/null +++ b/tools/station/tests/test_setup_pi.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Stub-Tests fuer setup_pi.sh - ohne Pi ausfuehrbar. +# +# ssh/scp werden im PATH durch Stubs ersetzt (Muster wie tests/test_deploy.sh): +# ssh fuehrt das per Heredoc uebergebene Remote-Skript lokal aus, scp uebersetzt +# "host:pfad"-Ziele auf den blossen lokalen Pfad und ruft dann "cp" auf. Die +# System-Zielpfade (/etc/ninlight-station.conf, /opt/ninlight-station, ...) sind +# per Umgebungsvariable auf ein Sandbox-Verzeichnis umgebogen (dasselbe Prinzip +# wie NINLIGHT_STATION_ROOT/_BIN/_UNIT in deploy.sh) - setup_pi.sh fasst damit +# nichts auf dem Testrechner selbst an ausser dem transienten /tmp, das schon +# deploy.sh fuer denselben Zweck benutzt. +# +# Schwerpunkt: Fixrunde 1, I4 - eine bestehende Konfiguration muss um fehlende +# Schluessel ergaenzt werden, ohne vorhandene Werte anzutasten. +# +# Aufruf: tools/station/tests/test_setup_pi.sh +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +STATION_DIR=$(cd "$HERE/.." && pwd) + +TMPROOT=$(mktemp -d) +trap 'rm -rf "$TMPROOT"' EXIT + +FAILED=0 +pass() { echo "ok - $1"; } +fail() { echo "FEHLER - $1" >&2; FAILED=1; } + +setup_stubbin() { + local bindir=$1 log=$2 + mkdir -p "$bindir" + + cat >"$bindir/ssh" <>"$log" +host=\$1; shift +"\$@" +EOF + + cat >"$bindir/scp" <>"$log" +args=() +for a in "\$@"; do + case "\$a" in + -*) continue ;; + *:*) args+=("\${a#*:}") ;; + *) args+=("\$a") ;; + esac +done +exec cp "\${args[@]}" +EOF + + # "sudo install -o root -g root ..." (im NetworkManager-Keyfile-Schritt) braucht + # echtes Root, um den Eigentuemer zu setzen - hat der Testrechner (anders als der + # Pi) nicht. Der Stub streicht -o/-g nur fuer "install", der Rest laeuft wie auf + # dem Pi ueber ein exec des ungestubbten Kommandos. + cat >"$bindir/sudo" <>"$log" +if [ "\$1" = "install" ]; then + args=() + skip=0 + for a in "\$@"; do + if [ "\$skip" -eq 1 ]; then skip=0; continue; fi + case "\$a" in + -o|-g) skip=1 ;; + *) args+=("\$a") ;; + esac + done + exec "\${args[@]}" +fi +exec "\$@" +EOF + + cat >"$bindir/systemctl" <>"$log" +case "\$1" in + is-active) exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" ;; + *) exit 0 ;; +esac +EOF + + cat >"$bindir/nmcli" <>"$log" +if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "DEVICE,STATE" ]; then + echo "wlan0:connected" + exit 0 +fi +exit 0 +EOF + + chmod +x "$bindir"/ssh "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl "$bindir"/nmcli +} + +# $1 = Sandbox-Wurzel fuer die "Systempfade" +setup_state() { + local state=$1 + rm -rf "$state" + mkdir -p "$state/etc" "$state/opt" "$state/usr/local/bin" "$state/etc/systemd/system" \ + "$state/etc/NetworkManager/system-connections" +} + +# Fuehrt setup_pi.sh mit gestubbtem PATH und auf die Sandbox umgebogenen +# Systempfaden aus. $1 = state-Verzeichnis, $2 = stub-bin-Verzeichnis. +run_setup() { + local state=$1 stubbin=$2 + PATH="$stubbin:$PATH" \ + NINLIGHT_SSH_BIN="$stubbin/ssh" \ + NINLIGHT_SCP_BIN="$stubbin/scp" \ + NINLIGHT_STATION_CONF="$state/etc/ninlight-station.conf" \ + NINLIGHT_STATION_ROOT="$state/opt/ninlight-station" \ + NINLIGHT_STATION_BIN="$state/usr/local/bin/ninlight-station" \ + NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \ + NINLIGHT_NM_DIR="$state/etc/NetworkManager/system-connections" \ + "$STATION_DIR/setup_pi.sh" pi-test +} + +echo "=== Szenario 1: frischer Pi ohne Konfiguration -> Vorlage wird komplett angelegt ===" +STATE="$TMPROOT/s1-state"; STUBBIN="$TMPROOT/bin1"; LOG="$TMPROOT/s1.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +if diff -q "$STATION_DIR/station.conf.example" "$STATE/etc/ninlight-station.conf" >/dev/null 2>&1; then + pass "Konfiguration wurde 1:1 aus der Vorlage angelegt" +else + fail "Konfiguration weicht von der Vorlage ab oder fehlt" +fi +[ -x "$STATE/opt/ninlight-station/server.py" ] && pass "server.py installiert" || fail "server.py fehlt" +[ -f "$STATE/opt/ninlight-station/products.py" ] && pass "products.py installiert" || fail "products.py fehlt (arm braucht es)" +[ -x "$STATE/usr/local/bin/ninlight-station" ] && pass "CLI installiert" || fail "ninlight-station CLI fehlt" +[ -f "$STATE/etc/systemd/system/ninlight-station.service" ] && pass "Unit installiert" || fail "Unit fehlt" +[ -d "$STATE/opt/ninlight-station/images" ] && pass "images/-Verzeichnis angelegt" || fail "images/-Verzeichnis fehlt" + +echo +echo "=== Szenario 2 (Fixrunde 1, I4): bestehende Konfiguration ohne die neuen Schluessel wird ergaenzt, nicht ersetzt ===" +STATE="$TMPROOT/s2-state"; STUBBIN="$TMPROOT/bin2"; LOG="$TMPROOT/s2.log" +setup_state "$STATE" +setup_stubbin "$STUBBIN" "$LOG" +cat >"$STATE/etc/ninlight-station.conf" <<'CONF' +SSID='eigene-ssid' +PSK='eigenes-passwort' +IMAGE_DIR='/opt/ninlight-station/images' +HOME_CONNECTION='preconfigured' +AP_CONNECTION='ninlight-recovery' +CONF +chmod 0600 "$STATE/etc/ninlight-station.conf" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +echo "$OUT" | grep -q "ergaenzt" && pass "Ausgabe kuendigt das Ergaenzen fehlender Schluessel an" || fail "Ausgabe erwaehnt 'ergaenzt' nicht: $OUT" +CONF_OUT="$STATE/etc/ninlight-station.conf" +if grep -Fqx "SSID='eigene-ssid'" "$CONF_OUT" && grep -Fqx "PSK='eigenes-passwort'" "$CONF_OUT"; then + pass "vorhandene Werte (SSID/PSK) blieben unveraendert" +else + fail "vorhandene Werte wurden ueberschrieben: $(cat "$CONF_OUT")" +fi +for KEY in MIGRATE_AP_CONNECTION MIGRATE_SSID MIGRATE_PSK EPD_REVISION; do + if grep -Eq "^${KEY}=" "$CONF_OUT"; then + pass "fehlender Schluessel $KEY wurde ergaenzt" + else + fail "Schluessel $KEY fehlt weiterhin in der Konfiguration" + fi +done +MODE=$(stat -f '%Lp' "$CONF_OUT" 2>/dev/null || stat -c '%a' "$CONF_OUT" 2>/dev/null) +[ "$MODE" = "600" ] && pass "Konfigurationsdatei bleibt 0600" || fail "Konfigurationsdatei hat falsche Rechte: $MODE" + +echo +echo "=== Szenario 3: erneuter Lauf auf einer bereits vollstaendigen Konfiguration ist ein echtes No-Op ===" +: >"$LOG" +set +e +OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +echo "$OUT" | grep -q "alle Schluessel vorhanden" && pass "meldet 'alle Schluessel vorhanden' statt erneut zu ergaenzen" || fail "Ausgabe: $OUT" +DUP=$(grep -c '^EPD_REVISION=' "$CONF_OUT") +[ "$DUP" -eq 1 ] && pass "EPD_REVISION steht nur einmal in der Datei (kein doppeltes Anhaengen)" || fail "EPD_REVISION steht $DUP mal in der Datei" + +echo +if [ "$FAILED" -eq 0 ]; then + echo "Alle Tests bestanden." +else + echo "Mindestens ein Test ist fehlgeschlagen." >&2 +fi +exit "$FAILED" diff --git a/tools/test_host.sh b/tools/test_host.sh index e2c93e2..ae6e337 100755 --- a/tools/test_host.sh +++ b/tools/test_host.sh @@ -13,3 +13,4 @@ else fi "$ROOT/tools/station/tests/test_cli.sh" +"$ROOT/tools/station/tests/test_setup_pi.sh"