Files
ninlight-w600/tools/station/deploy.sh
T
Michael Sauer eec9fa5a21 feat(station): Rettungsabbilder folgen dem Bildsatz-Umschalter beim Deploy
deploy.sh legt Rettungsabzuege jetzt in images/rescue-wlan/ (aus build/) und
images/rescue-nocreds/ (aus build-nocreds/) ab, statt sie flach in images/
abzulegen - dasselbe Muster wie migrate-wlan/migrate-nocreds, inklusive
derselben --delete-Vorsicht (nur bei vollstaendigem Satz). Alter flacher
Bestand wird bei jedem Deploy aufgeraeumt, auch bei einem gezielten.

README beschreibt den Umschalter jetzt fuer beide Betriebsarten.
2026-09-23 21:22:06 +02:00

654 lines
33 KiB
Bash
Executable File

#!/usr/bin/env bash
# Liefert Firmware-Images und den Stationscode an den Pi aus.
#
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
set -euo pipefail
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
shift || true
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
# Wo die Abzuege OHNE einkompilierte WLAN-Zugangsdaten liegen (tools/build_nocreds.sh,
# dieselbe Variable wie dort und in tools/release.sh). Hier schon gebraucht, nicht erst
# beim Signieren der Migrationsimages weiter unten: seit der Bildsatz-Umschalter auch die
# RETTUNG betrifft, braucht dieser Baum auch fuer die Rettungsabzuege (_ota.img statt
# _gz.img) einen eigenen Fundlauf, parallel zu dem fuer build/ unten.
NOCREDS_BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
declare -a FILES=()
declare -a RESCUE_NOCREDS_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")
# build-nocreds/ ist wie beim Signieren unten OPTIONAL/nachsichtig: ein Deploy darf
# nicht daran scheitern, dass tools/build_nocreds.sh fuer dieses Ziel noch nicht
# gelaufen ist. Fehlt die Datei, bleibt der Rettungs-Bildsatz "ohne-wlan" fuer GENAU
# dieses Ziel auf dem Pi unangetastet (sync_image_set unten arbeitet ohne --delete,
# wenn diese Liste nicht jedes Ziel aus FILES abdeckt).
nf="$ROOT/$NOCREDS_BUILD_REL/$t/ninlight_${t}_ota.img"
if [ -f "$nf" ]; then
RESCUE_NOCREDS_FILES+=("$nf")
else
echo "Hinweis: $nf fehlt - $t bleibt im Rettungs-Bildsatz 'ohne-wlan' unveraendert" >&2
fi
done
else
FULL_SYNC=1
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
# kein Zufall.
declare -A SEEN_AT=()
while IFS= read -r f; do
base=$(basename "$f")
if [ -n "${SEEN_AT[$base]:-}" ]; then
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
exit 1
fi
SEEN_AT[$base]=$f
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; }
# Derselbe Fundlauf fuer build-nocreds/ - genauso nachsichtig wie beim gezielten Deploy
# oben: fehlt der Baum komplett (tools/build_nocreds.sh nie gelaufen), bleibt
# RESCUE_NOCREDS_FILES einfach leer, kein Abbruch.
if [ -d "$ROOT/$NOCREDS_BUILD_REL" ]; then
declare -A SEEN_AT_NOCREDS=()
while IFS= read -r f; do
base=$(basename "$f")
if [ -n "${SEEN_AT_NOCREDS[$base]:-}" ]; then
echo "Fehler: $base mehrfach unter $NOCREDS_BUILD_REL gefunden - ${SEEN_AT_NOCREDS[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar." >&2
exit 1
fi
SEEN_AT_NOCREDS[$base]=$f
RESCUE_NOCREDS_FILES+=("$f")
done < <(find "$ROOT/$NOCREDS_BUILD_REL" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
fi
fi
echo "== Rettungsabzuege pruefen (build/, mit Zugangsdaten) =="
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
declare -A NINL_VERSION=()
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
# das faellt sonst erst am Geraet auf.
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
NINL_VERSION[$target]="$2"
# Landet jetzt in einem eigenen Unterverzeichnis (rescue-wlan/) statt flach in STAGE -
# server.py:image_path() folgt seit diesem Umschalter demselben Bildsatz-Umschalter
# wie migrate_dir(), und "mit-wlan" ist genau dieser Satz (siehe RESCUE_SET_DIRS dort).
mkdir -p "$STAGE/rescue-wlan"
cp "$f" "$STAGE/rescue-wlan/$base"
done
# Rettungsabzuege OHNE Zugangsdaten (build-nocreds/) - unabhaengig von den signierten
# Migrationsimages weiter unten: die Rettung braucht keine Signatur, nur den
# gewoehnlichen ninlight-Trailer, den nl_mkimage.py schon beim Bauen einsetzt. Ist eine
# Datei da, MUSS sie die Pruefung bestehen (Abbruch bei Fehlschlag, wie oben) - fehlt sie,
# ist das der lax behandelte Regelfall "tools/build_nocreds.sh noch nicht/nicht fuer
# jedes Ziel gelaufen" (siehe oben).
if [ ${#RESCUE_NOCREDS_FILES[@]} -gt 0 ]; then
echo "== Rettungsabzuege pruefen ($NOCREDS_BUILD_REL/, ohne Zugangsdaten) =="
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base (ohne Zugangsdaten) besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
mkdir -p "$STAGE/rescue-nocreds"
cp "$f" "$STAGE/rescue-nocreds/$base"
done
else
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Rettungs-Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
fi
# "Vollstaendig" heisst hier: dieser Lauf kennt fuer JEDES Ziel aus FILES (dem Satz
# 'mit-wlan') auch ein Gegenstueck ohne Zugangsdaten - erst dann darf der Abgleich auf
# dem Pi mit --delete arbeiten (dieselbe Ueberlegung wie bei NOCREDS_COMPLETE fuer die
# Migrationsimages weiter unten, nur ohne Signierschritt dazwischen).
RESCUE_NOCREDS_COMPLETE=1
if [ ${#RESCUE_NOCREDS_FILES[@]} -eq 0 ]; then
RESCUE_NOCREDS_COMPLETE=0
else
declare -A RESCUE_NOCREDS_TARGETS=()
for f in "${RESCUE_NOCREDS_FILES[@]}"; do
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
RESCUE_NOCREDS_TARGETS[$t]=1
done
for f in "${FILES[@]}"; do
base=$(basename "$f"); t=${base#ninlight_}; t=${t%_ota.img}
[ -n "${RESCUE_NOCREDS_TARGETS[$t]:-}" ] || RESCUE_NOCREDS_COMPLETE=0
done
fi
echo "== Migrationsimages signieren =="
# Die Signierschluessel gehoeren nie ins Repo und nie auf den Pi - sie werden hier nur
# gelesen, unter $NINLIGHT_SIGN_DIR (Vorgabe ~/workspace/fw-sign auf DIESEM Rechner).
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
# ausgeliefert (Exit-Code bleibt 0).
# ${HOME:-} statt $HOME: unter `set -u` (z.B. via cron oder env -i) waere ein
# unbound-variable-Abbruch hier voellig unnoetig, denn selbst ohne Migrationsimages
# sollen die Rettungsimages ausgeliefert werden (Fixrunde 1, Kleinigkeit 1).
SIGN_DIR=${NINLIGHT_SIGN_DIR:-${HOME:-}/workspace/fw-sign}
KEY_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
KEY_DEV="$SIGN_DIR/fw_sign.pem"
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py"
# MIGRATE_COMPLETE=1 bedeutet: dieser Lauf hat mit BEIDEN Schluesseln signiert, das
# lokale STAGE/migrate beschreibt also den vollstaendigen Satz an Migrationsvarianten
# fuer FILES. Nur dann darf der Abgleich auf dem Pi mit --delete arbeiten bzw. das
# Manifest blind ersetzen - sonst wuerden Images/Eintraege einer Variante verschwinden,
# die dieser Lauf mangels Schluessel gar nicht neu bauen konnte (Fixrunde 1, C2/I1).
MIGRATE_COMPLETE=0
# Dasselbe fuer den Satz OHNE WLAN-Zugangsdaten (siehe unten): "vollstaendig" heisst hier
# zusaetzlich, dass fuer JEDES Ziel ein Abzug aus build-nocreds/ vorlag - fehlt auch nur
# einer, darf --delete auf dem Pi nicht laufen.
NOCREDS_COMPLETE=0
# NOCREDS_BUILD_REL ist schon oben gesetzt (dort auch fuer die Rettungsabzuege gebraucht).
# Signiert einen kompletten Satz Migrationsimages und schreibt sein manifest.json.
# $1 = Quellverzeichnis der Abzuege (build bzw. build-nocreds), absolut
# $2 = Zielverzeichnis unterhalb von $STAGE (migrate-wlan bzw. migrate-nocreds)
# $3 = "strict": ein fehlendes _gz.img ist ein Abbruch. "lax": es wird uebersprungen.
# Gibt 0 zurueck, wenn der Satz VOLLSTAENDIG entstanden ist (kein Ziel uebersprungen),
# sonst 1. Legt $STAGE/$2 nur an, wenn wirklich etwas darin landet - ein leeres
# Verzeichnis wuerde auf dem Pi mit --delete einen vorhandenen Satz leerraeumen.
sign_set() {
local src_root=$1 stage_sub=$2 strictness=$3
local complete=1 produced=0
local f base target gz
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
# belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
# signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
# annimmt.
gz="$src_root/$target/ninlight_${target}_gz.img"
if [ ! -f "$gz" ]; then
if [ "$strictness" = "strict" ]; then
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1
fi
echo "Hinweis: $gz fehlt - $target fehlt im Bildsatz '$stage_sub'" >&2
complete=0
continue
fi
mkdir -p "$STAGE/$stage_sub"
if [ "$HAVE_PROD" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/$stage_sub/${target}_prod.img" \
|| { echo "Fehler: Signieren von $target (prod, $stage_sub) fehlgeschlagen" >&2; exit 1; }
produced=1
fi
if [ "$HAVE_DEV" -eq 1 ]; then
python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/$stage_sub/${target}_dev.img" \
|| { echo "Fehler: Signieren von $target (dev, $stage_sub) fehlgeschlagen" >&2; exit 1; }
produced=1
fi
done
[ "$produced" -eq 1 ] || return 1
local VERSION_ARGS=()
local t
for t in "${!NINL_VERSION[@]}"; do
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
done
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
# ueber das tatsaechlich abgelegte Image sagt. Form haelt sich an die Spec
# (docs/superpowers/spec, Abschnitt 4) und damit an das, was server.py tatsaechlich
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
# manifestweit - ein einziges Feld fuer das ganze Manifest war schon falsch, sobald
# der Merge unten (bei nur einem Schluessel oder gezieltem Deploy) alte und neue
# Eintraege mit unterschiedlichen Firmware-Versionen zusammenfuehrt. Das
# manifestweite Feld bleibt nur als Bestandskompatibilitaet erhalten und wird nur
# gesetzt, wenn wirklich alle Eintraege dieselbe Version tragen.
python3 - "$STAGE/$stage_sub" "${VERSION_ARGS[@]}" <<'PY'
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
migrate_dir = sys.argv[1]
version_map = dict(a.split("=", 1) for a in sys.argv[2:])
entries = []
for name in sorted(os.listdir(migrate_dir)):
if name.endswith("_prod.img"):
target, variant = name[: -len("_prod.img")], "prod"
elif name.endswith("_dev.img"):
target, variant = name[: -len("_dev.img")], "dev"
else:
continue
with open(os.path.join(migrate_dir, name), "rb") as fh:
data = fh.read()
entries.append({
"target": target,
"variant": variant,
"file": name,
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"ninlight_version": version_map.get(target, ""),
})
manifest = {
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"images": entries,
}
versions = {e["ninlight_version"] for e in entries}
if len(versions) == 1:
(version,) = versions
if version:
manifest["ninlight_version"] = version
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
json.dump(manifest, fh, indent=2, sort_keys=True)
fh.write("\n")
PY
[ "$complete" -eq 1 ]
}
HAVE_PROD=0
HAVE_DEV=0
if [ -d "$SIGN_DIR" ]; then
if [ -f "$KEY_PROD" ]; then
HAVE_PROD=1
else
echo "Hinweis: Produktionsschluessel fehlt - keine _prod.img-Migrationsimages" >&2
fi
if [ -f "$KEY_DEV" ]; then
HAVE_DEV=1
else
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
fi
if [ "$HAVE_PROD" -eq 1 ] || [ "$HAVE_DEV" -eq 1 ]; then
BOTH_KEYS=0
[ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ] && BOTH_KEYS=1
# ZWEI Bildsaetze, weil die WLAN-Zugangsdaten zur BAUZEIT in der Firmware stecken
# und die Station beide vorhalten soll (der Bediener schaltet per
# "ninlight-station bildsatz" bzw. am Taster um):
#
# migrate-wlan/ aus build/ - Lampe haengt sofort im eigenen WLAN
# migrate-nocreds/ aus build-nocreds/ - Lampe startet im Onboarding-AP
#
# Der Satz aus build/ ist streng: dort liegen die _ota.img, die oben schon geprueft
# wurden, ein fehlendes _gz.img daneben ist ein kaputter Baum. Der Satz aus
# build-nocreds/ ist nachsichtig: er entsteht aus einem EIGENEN, optionalen Bau
# (tools/build_nocreds.sh), und ein Deploy darf nicht daran scheitern, dass der noch
# nicht gelaufen ist - dann fehlt der Satz eben, und die Station liefert in dieser
# Stellung sichtbar nichts aus, statt heimlich den anderen Satz zu nehmen.
echo " Bildsatz migrate-wlan (aus build/)"
if sign_set "$ROOT/build" migrate-wlan strict; then
[ "$BOTH_KEYS" -eq 1 ] && MIGRATE_COMPLETE=1
fi
echo " Bildsatz migrate-nocreds (aus $NOCREDS_BUILD_REL/)"
if sign_set "$ROOT/$NOCREDS_BUILD_REL" migrate-nocreds lax; then
[ "$BOTH_KEYS" -eq 1 ] && NOCREDS_COMPLETE=1
else
if [ ! -d "$STAGE/migrate-nocreds" ]; then
echo "Hinweis: keine Abzuege unter $ROOT/$NOCREDS_BUILD_REL - der Bildsatz 'ohne-wlan' bleibt auf dem Pi unveraendert. Erzeugen mit tools/build_nocreds.sh." >&2
fi
fi
else
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
fi
else
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
fi
# Der Bildsatz "ohne-wlan" ist die SICHERE VORGABE: er geht im Automatikmodus an jede
# alte Klyqa-Lampe in Funkreichweite, auch an fremde, und das Flashen ist nicht
# umkehrbar. tools/release.sh prueft sein fertiges Archiv deshalb binaer gegen die Werte
# aus config.local.cmake (dort "Auf Zugangsdaten pruefen") - deploy.sh signierte
# dagegen ungeprueft, was in build-nocreds/ lag.
#
# Der Weg dorthin ist Bedienfehler, nicht Programmfehler: ein von Hand konfiguriertes
# build-nocreds/<ziel>, oder ein gesetztes NINLIGHT_NOCREDS_BUILD_REL=build. Das
# Ergebnis waere, dass das Panel "ohne WLAN" anzeigt, waehrend das WLAN-Passwort des
# Bedieners unwiderruflich in fremde Lampen wandert - der teure, nicht rueckholbare
# Fehler. Lieber ein abgebrochener Deploy.
#
# Geprueft wird VOR dem Uebertragen, es ist also noch nichts beim Pi angekommen. Nur
# dieser eine Satz: migrate-wlan/ SOLL die Zugangsdaten enthalten, das ist sein Zweck.
# Der Pruefkoerper ist bis auf die Meldung derselbe wie in tools/release.sh.
if [ -d "$STAGE/migrate-nocreds" ]; then
echo "== Bildsatz 'ohne-wlan' auf Zugangsdaten pruefen =="
LOCAL="$ROOT/config.local.cmake"
if [ -f "$LOCAL" ]; then
python3 - "$STAGE/migrate-nocreds" "$LOCAL" <<'CHECKPY' || exit 1
import os
import re
import sys
stage, local = sys.argv[1], sys.argv[2]
text = open(local, encoding="utf-8", errors="replace").read()
# Nur die drei Werte, die wirklich geheim sind. NINLIGHT_AP_PASSWORD und der
# Vorgabetoken stehen oeffentlich im Repo (README, Bruno) - sie hier mitzupruefen
# haette nur falsche Alarme erzeugt.
SECRETS = ("NINLIGHT_WIFI_SSID", "NINLIGHT_WIFI_PASSWORD", "NINLIGHT_TOKEN")
PUBLIC = {"", "ninlight", "aabbccddeeff0011223344", "ninlight-recovery", "change-me-please"}
needles = []
for name in SECRETS:
m = re.search(r'^\s*set\s*\(\s*%s\s+"([^"]*)"' % name, text, re.M)
if m and m.group(1) not in PUBLIC:
needles.append((name, m.group(1).encode()))
hits = []
for root, _dirs, files in os.walk(stage):
for fn in sorted(files):
path = os.path.join(root, fn)
with open(path, "rb") as fh:
data = fh.read()
for name, needle in needles:
if needle in data:
hits.append("%s in %s" % (name, os.path.relpath(path, stage)))
if hits:
sys.stderr.write(
"Fehler: Zugangsdaten aus config.local.cmake im Bildsatz 'ohne-wlan' gefunden, "
"nichts uebertragen:\n " + "\n ".join(hits) + "\n")
sys.exit(1)
print(" sauber (%d geprueft)" % len(needles))
CHECKPY
else
echo " uebersprungen - $LOCAL nicht vorhanden" >&2
fi
fi
echo "== uebertragen =="
# -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.
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" \
"$HERE/ninlight-station.service" "$HERE/ninlight-display.service" "$HERE/ninlight-button.service" \
"$HERE/ninlight-poweroff.service" "$HERE/ninlight-reboot.service" \
"$HOST:/tmp/"
# Das Anzeigeverzeichnis als Ganzes (render.py, display.py, epdconfig.py, die beiden
# uebernommenen Panel-Treiber und HERKUNFT.md - der Herkunftsnachweis gehoert mit auf
# den Pi, nicht nur ins Repo). --delete gilt hier nur fuer die fluechtige Ablage unter
# /tmp; __pycache__ bleibt draussen, sonst landen die .pyc dieses Rechners (andere
# Python-Version als auf dem Pi) im Zielverzeichnis und bleiben dort liegen.
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/display/" "$HOST:/tmp/ninlight-display/"
# Das Tasterverzeichnis genauso wie das Anzeigeverzeichnis: als Ganzes, ohne
# __pycache__ dieses Rechners.
rsync -a -c --delete --exclude=__pycache__ -e "$SSH_BIN" "$HERE/button/" "$HOST:/tmp/ninlight-button/"
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" "$MIGRATE_COMPLETE" "$NOCREDS_COMPLETE" "$RESCUE_NOCREDS_COMPLETE" <<'REMOTE'
set -e
FULL_SYNC=$1
MIGRATE_COMPLETE=$2
NOCREDS_COMPLETE=$3
RESCUE_NOCREDS_COMPLETE=$4
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}
DISPLAY_UNIT_PATH=${NINLIGHT_DISPLAY_UNIT:-/etc/systemd/system/ninlight-display.service}
BUTTON_UNIT_PATH=${NINLIGHT_BUTTON_UNIT:-/etc/systemd/system/ninlight-button.service}
POWEROFF_UNIT_PATH=${NINLIGHT_POWEROFF_UNIT:-/etc/systemd/system/ninlight-poweroff.service}
REBOOT_UNIT_PATH=${NINLIGHT_REBOOT_UNIT:-/etc/systemd/system/ninlight-reboot.service}
sudo install -d -m 0755 "$ROOT_DIR/images"
if [ "$FULL_SYNC" -eq 1 ]; then
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
REMOVED=""
if [ -n "$OLD_LIST" ]; then
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
fi
if [ -n "$REMOVED" ]; then
echo " voller Abgleich - folgende Images werden entfernt:"
echo "$REMOVED" | sed 's/^/ /'
else
echo " voller Abgleich - keine Images werden entfernt"
fi
# migrate-*/rescue-* werden gleich in einem eigenen Schritt behandelt (siehe unten) -
# hier ausklammern, sonst wuerde --delete sie leeren, wenn dieser Deploy ohne
# Schluesselverzeichnis bzw. ohne build-nocreds/ lief und die jeweilige Unterablage
# unter /tmp/ninlight-images deshalb gar nicht existiert.
#
# Seit diesem Umschalter landet HIER (flach in images/) nichts Neues mehr: die
# Rettungsabzuege ziehen mit rescue-wlan/rescue-nocreds nach demselben Muster wie die
# Migrationsabzuege um. Ein alter, noch flacher Bestand aus der Zeit vor diesem
# Umschalter verschwindet dadurch bei einem vollen Deploy automatisch (die Quelle
# unter /tmp/ninlight-images hat dort ja nichts mehr) - kein separater Aufraeumschritt
# noetig, dieselbe Mechanik, die weiter unten "images/migrate" entsorgt.
sudo rsync -a -c --delete --exclude=/migrate-wlan --exclude=/migrate-nocreds \
--exclude=/rescue-wlan --exclude=/rescue-nocreds /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 --exclude=/migrate-wlan --exclude=/migrate-nocreds \
--exclude=/rescue-wlan --exclude=/rescue-nocreds /tmp/ninlight-images/ "$ROOT_DIR/images/"
fi
# $1 = Name des Bildsatzverzeichnisses, $2 = 1, wenn dieser Lauf den Satz VOLLSTAENDIG
# beschreibt (bei den Migrationssaetzen: beide Schluessel, alle Ziele; bei den
# Rettungssaetzen: jedes Ziel aus FILES vorhanden) und --delete deshalb erlaubt ist.
#
# Fuer alle vier Bildsatz-Verzeichnisse gleichermassen (migrate-wlan/migrate-nocreds UND
# seit diesem Umschalter auch rescue-wlan/rescue-nocreds, daher der generische Name):
# fehlt ein Satz in der Uebertragung (kein Schluesselverzeichnis, kein
# tools/build_nocreds.sh gelaufen, ...), darf ein vorhandener Satz auf dem Pi NICHT
# geleert werden - sonst zerstoert ein unvollstaendiger Deploy die Bestueckung
# frueherer Deploys (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur
# diesmal beim Fehlen statt beim Ersetzen). Die Manifest-Zusammenfuehrung weiter unten
# betrifft dabei nur Migrationssaetze - ein Rettungssatz hat kein manifest.json (die
# Version steckt im ninlight-Trailer jedes einzelnen Images), das Zusammenfuehren
# greift dort also einfach nicht.
sync_image_set() {
set=$1
complete=$2
if [ -d "/tmp/ninlight-images/$set" ]; then
sudo install -d -m 0755 "$ROOT_DIR/images/$set"
if [ "$FULL_SYNC" -eq 1 ] && [ "$complete" -eq 1 ]; then
# Nur wenn dieser Lauf mit BEIDEN Schluesseln signiert hat, beschreibt
# STAGE/migrate den vollstaendigen Soll-Zustand - erst dann darf --delete
# ueberzaehlige Dateien wegraeumen und manifest.json blind ersetzt werden.
sudo rsync -a -c --delete "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
else
# Gezielter Deploy ODER ein Voll-Deploy mit nur einem der beiden Schluessel:
# in beiden Faellen kennt dieser Lauf nur einen Teil dessen, was auf dem Pi liegen
# soll. rsync ohne --delete laesst alles Nicht-Uebertragene unangetastet (Fixrunde
# 1, C2 - sonst raeumt ein Voll-Deploy mit nur dem dev-Schluessel saemtliche
# _prod.img weg). manifest.json wird deshalb auch nicht einfach ueberschrieben,
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
sudo rsync -a -c --exclude=/manifest.json "/tmp/ninlight-images/$set/" "$ROOT_DIR/images/$set/"
if [ -f "/tmp/ninlight-images/$set/manifest.json" ]; then
sudo python3 - "$ROOT_DIR/images/$set/manifest.json" "/tmp/ninlight-images/$set/manifest.json" <<'MERGE'
import json
import sys
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
with open(new_manifest_path) as fh:
new_manifest = json.load(fh)
if not isinstance(new_manifest, dict):
new_manifest = {}
try:
with open(manifest_path) as fh:
old_manifest = json.load(fh)
except (OSError, ValueError):
old_manifest = {}
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
# kein Objekt ist) - das darf niemals mitten im Deploy mit AttributeError abbrechen,
# denn dieser Merge steht VOR der Installation von server.py/products.py/CLI/Unit und
# vor dem Neustart: ein Abbruch hier liesse den Pi mit neuen Images, aber altem
# Stationscode zurueck.
if not isinstance(old_manifest, dict):
old_manifest = {}
old_images = old_manifest.get("images")
if not isinstance(old_images, list):
old_images = []
by_key = {(e.get("target"), e.get("variant")): e for e in old_images if isinstance(e, dict)}
new_images = new_manifest.get("images")
if not isinstance(new_images, list):
new_images = []
for e in new_images:
if isinstance(e, dict):
by_key[(e.get("target"), e.get("variant"))] = e
merged_images = sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or ""))
merged = {
"built": new_manifest.get("built"),
"images": merged_images,
}
# Fixrunde 2, N2: das manifestweite "ninlight_version" darf nach dem Merge nicht mehr
# einfach vom neuen Manifest uebernommen werden - die uebernommenen Alt-Eintraege
# koennen eine andere Version tragen (das war die alte Version dieses Bugs: ein
# Voll-Deploy mit nur dem dev-Schluessel hat die neue Firmware-Version ueber ein
# unveraendert liegen gebliebenes altes _prod.img geschrieben). Jeder Eintrag traegt
# seine eigene Version; das Top-Level-Feld ist nur noch eine Bequemlichkeit fuer den
# Fall, dass wirklich alle Eintraege uebereinstimmen.
versions = {e.get("ninlight_version") for e in merged_images}
if len(versions) == 1:
(version,) = versions
if isinstance(version, str) and version:
merged["ninlight_version"] = version
with open(manifest_path, "w") as fh:
json.dump(merged, fh, indent=2, sort_keys=True)
fh.write("\n")
MERGE
fi
fi
else
# Weder "kein Schluesselverzeichnis"/"build-nocreds/ fehlt" noch "Verzeichnis
# vorhanden, aber leer" laesst sich von hier aus unterscheiden - beide Faelle liefern
# ein leeres $set/ von diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...")
# ist bereits genau; diese hier behauptet absichtlich keinen bestimmten Grund
# (Fixrunde 2, Kleinigkeit).
echo " keine Abzuege fuer $set von diesem Rechner - $set/ auf dem Pi bleibt unangetastet"
fi
}
# Alle vier Bildsaetze. Sie sind voneinander unabhaengig: liegt nur einer von diesem
# Rechner vor (z.B. weil tools/build_nocreds.sh noch nicht gelaufen ist), bleibt der
# andere auf dem Pi unveraendert - der Umschalter zeigt dann eben auf einen Satz, der
# fehlt, und die Station liefert in dieser Stellung sichtbar nichts aus.
sync_image_set migrate-wlan "$MIGRATE_COMPLETE"
sync_image_set migrate-nocreds "$NOCREDS_COMPLETE"
# rescue-wlan ist, wenn es ueberhaupt uebertragen wurde, IMMER vollstaendig: es entsteht
# 1:1 aus FILES (dem verifizierten Satz aus build/), es gibt keinen Zwischenschritt wie
# das Signieren, der es unvollstaendig lassen koennte. "1" ist hier deshalb kein
# Versehen, sondern dieselbe Bedeutung wie MIGRATE_COMPLETE=1 bei zwei vorhandenen
# Schluesseln.
sync_image_set rescue-wlan 1
sync_image_set rescue-nocreds "$RESCUE_NOCREDS_COMPLETE"
# Altbestand: bis zum Umschalter hiess das einzige Verzeichnis "migrate" und enthielt die
# Abzuege MIT WLAN-Zugangsdaten. Es wird von niemandem mehr gelesen; liegen bleiben darf
# es trotzdem nicht, sonst glaubt der naechste Mensch am Pi, es sei der gueltige Satz.
if [ -d "$ROOT_DIR/images/migrate" ]; then
sudo rm -rf "$ROOT_DIR/images/migrate"
echo " Altbestand entfernt: images/migrate (heisst jetzt images/migrate-wlan)"
fi
# Derselbe Altbestand-Fall fuer die Rettung: vor diesem Umschalter lagen Rettungsabzuege
# FLACH in images/ (immer MIT Zugangsdaten, aus build/) - server.py:image_path() liest
# dort seit diesem Umschalter nicht mehr (siehe rescue_dir() dort fuer die Begruendung,
# warum bewusst KEIN Fallback). Ein voller Deploy raeumt sie oben bereits über die
# normale --delete-Mechanik weg (die Quelle unter /tmp/ninlight-images hat dort ja
# nichts mehr); ein GEZIELTER Deploy laesst sie aber, wie jeden anderen nicht
# uebertragenen Inhalt von images/, unangetastet liegen (kein --delete). Dieser
# Aufraeumschritt laeuft deshalb IMMER, unabhaengig von FULL_SYNC - er ist rein additiv
# (loescht hoechstens toten, nie wieder gelesenen Ballast) und macht so auch einen
# gezielten Deploy sofort sauber, statt erst auf den naechsten vollen zu warten.
OLD_FLAT_RESCUE=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null)
if [ -n "$OLD_FLAT_RESCUE" ]; then
echo "$OLD_FLAT_RESCUE" | xargs -r sudo rm -f
echo " Altbestand entfernt: Rettungsabbilder, die noch flach in images/ lagen (liegen jetzt in images/rescue-wlan bzw. images/rescue-nocreds)"
fi
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"
# Anzeige: Verzeichnis und Unit. --delete ist hier richtig (anders als bei den Images):
# display/ ist reiner Code aus dem Repo, es gibt keinen Bestand, der einen Deploy
# ueberleben muesste - eine im Repo geloeschte Datei muss auch hier verschwinden.
sudo install -d -m 0755 "$ROOT_DIR/display"
sudo rsync -a -c --delete /tmp/ninlight-display/ "$ROOT_DIR/display/"
sudo install -m 0644 /tmp/ninlight-display.service "$DISPLAY_UNIT_PATH"
# Taster: dieselbe Begruendung wie bei der Anzeige - button/ ist reiner Code aus dem
# Repo, es gibt keinen Bestand, der einen Deploy ueberleben muesste.
sudo install -d -m 0755 "$ROOT_DIR/button"
sudo rsync -a -c --delete /tmp/ninlight-button/ "$ROOT_DIR/button/"
sudo install -m 0644 /tmp/ninlight-button.service "$BUTTON_UNIT_PATH"
# Abschlussbild beim Herunterfahren/Neustart. Nur die Dateien - aktiviert werden sie in
# setup_pi.sh (ein Deploy soll keine Units einschalten, die vorher aus waren).
sudo install -m 0644 /tmp/ninlight-poweroff.service "$POWEROFF_UNIT_PATH"
sudo install -m 0644 /tmp/ninlight-reboot.service "$REBOOT_UNIT_PATH"
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service \
/tmp/ninlight-display /tmp/ninlight-display.service /tmp/ninlight-button /tmp/ninlight-button.service \
/tmp/ninlight-poweroff.service /tmp/ninlight-reboot.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
# Die Anzeige laeuft mit dem gerade ersetzten Code weiter, bis sie neu gestartet wird.
# Ein Fehlschlag ist hier aber ausdruecklich KEIN Deploy-Fehler (anders als oben beim
# Hauptdienst): die Anzeige ist nirgends Voraussetzung fuer eine Migration, und ein
# Deploy, der wegen des Panels rot wird, verleitet nur dazu, den echten Fehler oben zu
# uebersehen.
if systemctl is-active --quiet ninlight-display; then
if sudo systemctl restart ninlight-display; then
echo " Anzeigedienst neu gestartet"
else
echo " Warnung: Anzeigedienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-display" >&2
fi
else
echo " Anzeigedienst laeuft nicht"
fi
# Taster: dieselbe weiche Behandlung wie die Anzeige (siehe deren Begruendung oben) - er
# ist nirgends Voraussetzung fuer eine Migration, ein Deploy darf daran nicht scheitern.
if systemctl is-active --quiet ninlight-button; then
if sudo systemctl restart ninlight-button; then
echo " Tasterdienst neu gestartet"
else
echo " Warnung: Tasterdienst lief, Neustart ist fehlgeschlagen - journalctl -u ninlight-button" >&2
fi
else
echo " Tasterdienst laeuft nicht"
fi
REMOTE
echo "fertig."