diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index fdbc7cd..6bc791c 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -12,12 +12,17 @@ MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py" declare -a FILES=() if [ $# -gt 0 ]; then + # Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle + # anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC + # unten). + FULL_SYNC=0 for t in "$@"; do f="$ROOT/build/$t/ninlight_${t}_ota.img" [ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; } FILES+=("$f") done else + FULL_SYNC=1 while IFS= read -r f; do FILES+=("$f"); done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort) [ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; } fi @@ -40,16 +45,52 @@ for f in "${FILES[@]}"; do done echo "== uebertragen ==" -rsync -a --delete "$STAGE/" "$HOST:/tmp/ninlight-images/" +# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues +# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei +# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer +# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde +# rsync ein solches Update dann stillschweigend ueberspringen. +# +# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen +# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live +# ausgelieferte Verzeichnis. +rsync -a -c --delete "$STAGE/" "$HOST:/tmp/ninlight-images/" rsync -a "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" -ssh "$HOST" 'set -e - sudo install -d -m 0755 /opt/ninlight-station/images - sudo rsync -a --delete /tmp/ninlight-images/ /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 -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service - sudo systemctl daemon-reload - systemctl is-active --quiet ninlight-station && { sudo systemctl restart ninlight-station; echo " Dienst neu gestartet"; } || echo " Dienst laeuft nicht (Station ist aus)"' +# Pfade als Variablen mit Default: so kann ein Test die Installation gegen +# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. +ssh "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE' +set -e +FULL_SYNC=$1 +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/images" +if [ "$FULL_SYNC" -eq 1 ]; then + # Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen. + echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt" + sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/" +else + # Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle + # anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete). + echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten" + sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/" +fi +sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py" +sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH" +sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" +rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service +sudo systemctl daemon-reload +if systemctl is-active --quiet ninlight-station; then + if sudo systemctl restart ninlight-station; then + echo " Dienst neu gestartet" + else + echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2 + exit 1 + fi +else + echo " Dienst laeuft nicht (Station ist aus)" +fi +REMOTE echo "fertig." diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 974ea64..ac212b0 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -9,9 +9,19 @@ set -eu CONF=/etc/ninlight-station.conf -[ -r "$CONF" ] || { echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2; exit 1; } -# shellcheck disable=SC1090 -. "$CONF" +if [ -r "$CONF" ]; then + # shellcheck disable=SC1090 + . "$CONF" +elif [ -e "$CONF" ]; then + # $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar + # ist. Der dokumentierte Aufruf "ssh ninlight-station on" laeuft + # aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre + # Rechte aufzuweichen. + eval "$(sudo cat "$CONF")" +else + echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2 + exit 1 +fi for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION; do eval "test -n \"\${$_key-}\"" || { diff --git a/tools/station/setup_pi.sh b/tools/station/setup_pi.sh index c659b2f..0ebcfff 100755 --- a/tools/station/setup_pi.sh +++ b/tools/station/setup_pi.sh @@ -37,23 +37,60 @@ ssh "$HOST" 'set -e echo " installiert"' echo "== NetworkManager-Profil ==" -ssh "$HOST" 'set -e - . /etc/ninlight-station.conf - if nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION"; then - sudo nmcli con mod "$AP_CONNECTION" \ - 802-11-wireless.ssid "$SSID" wifi-sec.psk "$PSK" \ - ipv4.addresses 192.168.8.1/24 ipv4.method shared \ - connection.autoconnect no - echo " Profil $AP_CONNECTION aktualisiert" - else - sudo nmcli con add type wifi ifname wlan0 con-name "$AP_CONNECTION" ssid "$SSID" \ - 802-11-wireless.mode ap 802-11-wireless.band bg 802-11-wireless.channel 6 \ - wifi-sec.key-mgmt wpa-psk wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp \ - wifi-sec.psk "$PSK" \ - ipv4.method shared ipv4.addresses 192.168.8.1/24 ipv6.method disabled \ - connection.autoconnect no >/dev/null - echo " Profil $AP_CONNECTION angelegt" - fi' +# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf: +# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht +# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes +# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied" +# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der +# Datei aufzuweichen. +# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen- +# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per +# "ps"/"/proc//cmdline" sichtbar. Deshalb wird das Verbindungsprofil +# stattdessen direkt als NetworkManager-Keyfile (0600 root:root) +# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer +# Prozessliste. +ssh "$HOST" bash -s <<'REMOTE' +set -e +eval "$(sudo cat /etc/ninlight-station.conf)" +CONN_FILE="/etc/NetworkManager/system-connections/${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) + ACTION="angelegt" +fi +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT +{ + printf '%s\n' '[connection]' + printf 'id=%s\n' "$AP_CONNECTION" + printf 'uuid=%s\n' "$UUID" + printf '%s\n' 'type=wifi' + printf '%s\n' 'interface-name=wlan0' + printf '%s\n' 'autoconnect=false' + printf '\n%s\n' '[wifi]' + printf '%s\n' 'mode=ap' + printf 'ssid=%s\n' "$SSID" + printf '%s\n' 'band=bg' + printf '%s\n' 'channel=6' + printf '\n%s\n' '[wifi-security]' + printf '%s\n' 'key-mgmt=wpa-psk' + printf '%s\n' 'proto=rsn' + printf '%s\n' 'pairwise=ccmp;' + printf '%s\n' 'group=ccmp;' + printf 'psk=%s\n' "$PSK" + printf '\n%s\n' '[ipv4]' + printf '%s\n' 'method=shared' + printf '%s\n' 'address1=192.168.8.1/24' + printf '\n%s\n' '[ipv6]' + printf '%s\n' 'method=disabled' +} >"$TMP" +sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE" +rm -f "$TMP" +sudo nmcli connection load "$CONN_FILE" >/dev/null +echo " Profil $AP_CONNECTION $ACTION" +REMOTE cat <<'EOF' diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh new file mode 100755 index 0000000..504fc84 --- /dev/null +++ b/tools/station/tests/test_deploy.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Stub-Tests fuer deploy.sh - ohne Pi ausfuehrbar. +# +# ssh/rsync werden im PATH durch Stubs ersetzt: ssh fuehrt das per Heredoc +# uebergebene Remote-Skript lokal aus (statt es an einen echten Pi zu +# schicken), rsync uebersetzt "host:pfad"-Ziele auf den blossen lokalen Pfad +# und ruft dann den echten rsync auf. So laeuft die komplette Logik von +# deploy.sh - inklusive des Installationsschritts, der normalerweise per SSH +# auf dem Pi passiert - tatsaechlich, aber vollstaendig gegen ein Sandbox- +# Verzeichnis (NINLIGHT_STATION_ROOT/…) statt gegen echte Systempfade. +# +# Aufruf: tools/station/tests/test_deploy.sh +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +STATION_DIR=$(cd "$HERE/.." && pwd) +REAL_RSYNC=$(command -v rsync) + +TMPROOT=$(mktemp -d) +trap 'rm -rf "$TMPROOT"' EXIT + +FAILED=0 +pass() { echo "ok - $1"; } +fail() { echo "FEHLER - $1" >&2; FAILED=1; } + +# --- Sandbox-Repo aufbauen: tools/station/deploy.sh + tools/mkimage/nl_mkimage.py + build/ --- +setup_sandbox() { + local sandbox=$1 + rm -rf "$sandbox" + mkdir -p "$sandbox/tools/station" "$sandbox/tools/mkimage" "$sandbox/build" + cp "$STATION_DIR/deploy.sh" "$sandbox/tools/station/deploy.sh" + chmod +x "$sandbox/tools/station/deploy.sh" + # Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen"). + echo "# dummy" >"$sandbox/tools/station/server.py" + echo "# dummy" >"$sandbox/tools/station/ninlight-station" + echo "# dummy" >"$sandbox/tools/station/ninlight-station.service" + + # Fake nl_mkimage.py --verify: verhaelt sich wie das echte Tool (eine Zeile + # " " auf stdout bei Erfolg, Exit 0; eine Zeile auf + # stderr bei Fehlschlag, Exit 1), aber steuerbar per Umgebungsvariable + # MKIMAGE_FAIL_SUBSTR: enthaelt der zu pruefende Dateiname diesen Teilstring, + # schlaegt die Pruefung fehl. + cat >"$sandbox/tools/mkimage/nl_mkimage.py" <<'PY' +import os +import sys + +args = sys.argv[1:] +f = args[args.index("--verify") + 1] +target = args[args.index("--expect-device") + 1] +fail_substr = os.environ.get("MKIMAGE_FAIL_SUBSTR", "") +if fail_substr and fail_substr in f: + print(f"Fehler: {f} besteht die Trailer-Pruefung nicht (Test-Stub)", file=sys.stderr) + sys.exit(1) +size = os.path.getsize(f) +print(f"{target} 1.0.0 {size}") +PY +} + +# --- Stub-Kommandos fuer ssh/rsync im PATH --- +setup_stubbin() { + local bindir=$1 log=$2 + mkdir -p "$bindir" + + cat >"$bindir/ssh" <>"$log" +host=\$1; shift +"\$@" +EOF + + cat >"$bindir/rsync" <>"$log" +args=() +for a in "\$@"; do + case "\$a" in + -*) args+=("\$a") ;; + *:*) args+=("\${a#*:}") ;; + *) args+=("\$a") ;; + esac +done +exec "$REAL_RSYNC" "\${args[@]}" +EOF + + cat >"$bindir/scp" <>"$log" +exit 1 +EOF + + cat >"$bindir/sudo" <>"$log" +exec "\$@" +EOF + + cat >"$bindir/systemctl" <>"$log" +case "\$1" in + is-active) exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-1}" ;; + restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;; + *) exit 0 ;; +esac +EOF + + chmod +x "$bindir"/ssh "$bindir"/rsync "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl +} + +run_deploy() { + # Fuehrt deploy.sh mit stubbtem PATH aus; Ausgabe landet in $OUT, Exit-Code in $?. + local sandbox=$1; shift + # Auf dem echten Pi existieren /usr/local/bin und /etc/systemd/system schon; + # in der Sandbox muessen sie fuer "install" eigens angelegt werden. + mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system" + PATH="$STUBBIN:$PATH" \ + NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \ + NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \ + NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \ + "$sandbox/tools/station/deploy.sh" "$@" +} + +echo "=== Szenario 1: Image besteht die Trailer-Pruefung nicht -> nichts uebertragen ===" +SANDBOX="$TMPROOT/s1"; STUBBIN="$TMPROOT/bin1"; LOG="$TMPROOT/s1.log"; SANDBOX_STATE="$TMPROOT/s1-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "dummy" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(MKIMAGE_FAIL_SUBSTR=foo run_deploy "$SANDBOX" pi-test foo 2>&1) +RC=$? +set -e +if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi +if echo "$OUT" | grep -q "besteht die Trailer-Pruefung nicht"; then + pass "Fehlermeldung zur Trailer-Pruefung vorhanden" +else + fail "Fehlermeldung zur Trailer-Pruefung fehlt" +fi +if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then + pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen" +else + fail "ssh/rsync/scp wurde trotz fehlgeschlagener Pruefung aufgerufen: $(cat "$LOG")" +fi + +echo +echo "=== Szenario 2: keine Images unter build/ gefunden ===" +SANDBOX="$TMPROOT/s2"; STUBBIN="$TMPROOT/bin2"; LOG="$TMPROOT/s2.log"; SANDBOX_STATE="$TMPROOT/s2-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +: >"$LOG" +set +e +OUT=$(run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi +if echo "$OUT" | grep -q "keine Images unter"; then + pass "Fehlermeldung zu fehlenden Images vorhanden" +else + fail "Fehlermeldung zu fehlenden Images fehlt" +fi +if ! grep -q "STUB" "$LOG" 2>/dev/null; then + pass "kein Transfer-Kommando aufgerufen" +else + fail "ein Transfer-Kommando wurde trotz fehlender Images aufgerufen: $(cat "$LOG")" +fi + +echo +echo "=== Szenario 3: gezielter Deploy eines Geraetetyps laesst andere Images auf dem Ziel unangetastet (Critical 2) ===" +SANDBOX="$TMPROOT/s3"; STUBBIN="$TMPROOT/bin3"; LOG="$TMPROOT/s3.log"; SANDBOX_STATE="$TMPROOT/s3-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar" +echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img" +# Zielverzeichnis simuliert einen vorherigen Voll-Deploy: Images fuer foo UND bar liegen schon da. +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images" +echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" +echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" +: >"$LOG" +set +e +OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1) +RC=$? +set -e +if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi +if echo "$OUT" | grep -q "andere Geraetetypen im Zielverzeichnis bleiben erhalten"; then + pass "Ausgabe kuendigt gezielten Deploy ohne --delete an" +else + fail "Ausgabe erwaehnt den gezielten Deploy nicht" +fi +if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then + pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten" +else + fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben" +fi +if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \ + && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img")" = "foo-image-neu" ]; then + pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert" +else + fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert" +fi + +echo +echo "=== Szenario 4: Voll-Deploy (kein target) entfernt veraltete Images - zur Abgrenzung von Szenario 3 ===" +SANDBOX="$TMPROOT/s4"; STUBBIN="$TMPROOT/bin4"; LOG="$TMPROOT/s4.log"; SANDBOX_STATE="$TMPROOT/s4-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images" +echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" +: >"$LOG" +set +e +OUT=$(run_deploy "$SANDBOX" pi-test 2>&1) +RC=$? +set -e +if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi +if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then + pass "veraltetes Image wurde beim Voll-Deploy entfernt (--delete wirkt weiterhin, wenn gewollt)" +else + fail "veraltetes Image wurde beim Voll-Deploy NICHT entfernt" +fi + +echo +echo "=== Szenario 5: Dienst laeuft, Neustart nach dem Deploy schlaegt fehl -> Exit != 0 (Minor) ===" +SANDBOX="$TMPROOT/s5"; STUBBIN="$TMPROOT/bin5"; LOG="$TMPROOT/s5.log"; SANDBOX_STATE="$TMPROOT/s5-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system" +echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \ + PATH="$STUBBIN:$PATH" \ + NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \ + NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \ + NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \ + "$SANDBOX/tools/station/deploy.sh" pi-test foo 2>&1) +RC=$? +set -e +if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0, wenn der Neustart fehlschlaegt ($RC)"; else fail "Exit-Code war 0, obwohl der Neustart fehlschlug"; fi +if echo "$OUT" | grep -q "Neustart nach dem Deploy ist aber fehlgeschlagen"; then + pass "Fehlermeldung unterscheidet Neustart-Fehlschlag von 'Dienst laeuft nicht'" +else + fail "Fehlermeldung fehlt oder ist mehrdeutig" +fi +if echo "$OUT" | grep -q "Dienst laeuft nicht"; then + fail "faelschlich 'Dienst laeuft nicht' gemeldet, obwohl der Dienst lief" +else + pass "'Dienst laeuft nicht' wird nicht faelschlich gemeldet" +fi + +echo +if [ "$FAILED" -eq 0 ]; then + echo "Alle Tests bestanden." +else + echo "Mindestens ein Test ist fehlgeschlagen." >&2 +fi +exit "$FAILED"