#!/usr/bin/env bash # Liefert Firmware-Images und den Stationscode an den Pi aus. # # Aufruf: tools/station/deploy.sh [target ...] # Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen. set -euo pipefail HOST=${1:?Aufruf: tools/station/deploy.sh [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} 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 # 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; } fi echo "== Images pruefen ==" 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" cp "$f" "$STAGE/$base" done 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 if [ -d "$SIGN_DIR" ]; then HAVE_PROD=0 HAVE_DEV=0 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 # Erst HIER, nachdem feststeht dass ueberhaupt signiert wird, STAGE/migrate anlegen # und das fehlende _gz.img zum Abbruch machen (Fixrunde 1, C2+C3): ein # Schluesselverzeichnis ohne jeden Schluessel darf weder ein leeres migrate/ # erzeugen (das wuerde ein vorhandenes migrate/ auf dem Pi mit --delete leerraeumen) # noch am Fehlen des _gz.img scheitern, wenn gar nicht signiert werden soll. mkdir -p "$STAGE/migrate" 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="$ROOT/build/$target/ninlight_${target}_gz.img" [ -f "$gz" ] || { echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; } if [ "$HAVE_PROD" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_PROD" --in "$gz" --out "$STAGE/migrate/${target}_prod.img" \ || { echo "Fehler: Signieren von $target (prod) fehlgeschlagen" >&2; exit 1; } fi if [ "$HAVE_DEV" -eq 1 ]; then python3 "$NL_SIGN" sign --key "$KEY_DEV" --in "$gz" --out "$STAGE/migrate/${target}_dev.img" \ || { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; } fi done if [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ]; then MIGRATE_COMPLETE=1 fi VERSION_ARGS=() 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/migrate" "${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 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 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" "$HOST:/tmp/" # 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" <<'REMOTE' set -e FULL_SYNC=$1 MIGRATE_COMPLETE=$2 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. # 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/ wird gleich in einem eigenen Schritt behandelt (siehe unten) - hier # ausklammern, sonst wuerde --delete es leeren, wenn dieser Deploy ohne # Schluesselverzeichnis lief und /tmp/ninlight-images/migrate deshalb gar nicht # existiert. sudo rsync -a -c --delete --exclude=/migrate /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 /tmp/ninlight-images/ "$ROOT_DIR/images/" fi # Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf # dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel # vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden - # sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys # (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen # statt beim Ersetzen). if [ -d /tmp/ninlight-images/migrate ]; then sudo install -d -m 0755 "$ROOT_DIR/images/migrate" if [ "$FULL_SYNC" -eq 1 ] && [ "$MIGRATE_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/migrate/ "$ROOT_DIR/images/migrate/" 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/migrate/ "$ROOT_DIR/images/migrate/" if [ -f /tmp/ninlight-images/migrate/manifest.json ]; then sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/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" noch "Verzeichnis vorhanden, aber leer" laesst # sich von hier aus unterscheiden - beide Faelle liefern ein leeres migrate/ von # diesem Rechner. Die lokale Meldung (deploy.sh, "Hinweis: ...") ist bereits genau; # diese hier behauptet absichtlich keinen bestimmten Grund (Fixrunde 2, Kleinigkeit). echo " keine Migrationsimages von diesem Rechner - migrate/ auf dem Pi bleibt unangetastet" 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" rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.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."