diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index 0539531..12892a5 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -72,13 +72,21 @@ echo "== Migrationsimages signieren ==" # 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). -SIGN_DIR=${NINLIGHT_SIGN_DIR:-$HOME/workspace/fw-sign} +# ${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 - mkdir -p "$STAGE/migrate" HAVE_PROD=0 HAVE_DEV=0 if [ -f "$KEY_PROD" ]; then @@ -91,36 +99,51 @@ if [ -d "$SIGN_DIR" ]; then else echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 fi - 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; } + 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 - 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 - 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. - python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY' + 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: "ninlight_version" auf oberster Ebene, nicht pro Eintrag (Fixrunde 1, C1 - + # der Server las vorher immer "unbekannt"). + 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:]) @@ -137,14 +160,33 @@ for name in sorted(os.listdir(migrate_dir)): entries.append({ "target": target, "variant": variant, + "file": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), - "ninlight_version": version_map.get(target, ""), }) + +# Die Spec kennt nur EINE Firmware-Version pro Manifest, nicht eine pro Ziel. Im +# Regelfall bauen alle Ziele eines Deploys aus demselben Firmware-Stand, weichen die +# Versionen trotzdem ab, ist das kein Grund zum Abbrechen - nur eine Warnung, und die +# alphabetisch erste Version gewinnt (deterministisch, nachvollziehbar). +versions = sorted({version_map.get(e["target"], "") for e in entries}) +if len(versions) > 1: + print(f"Warnung: unterschiedliche Firmware-Versionen in diesem Deploy {versions} " + f"- manifest.json verwendet {versions[0]}", file=sys.stderr) +ninlight_version = versions[0] if versions else "" + +manifest = { + "built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "ninlight_version": ninlight_version, + "images": entries, +} with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh: - json.dump({"images": entries}, fh, indent=2, sort_keys=True) + 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 @@ -164,9 +206,10 @@ 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" <<'REMOTE' +"$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} @@ -202,16 +245,54 @@ else 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 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). +# 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 ]; then + 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 - sudo rsync -a -c /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/" + # 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) +try: + with open(manifest_path) as fh: + old_manifest = json.load(fh) +except (OSError, ValueError): + old_manifest = {"images": []} +by_key = {(e.get("target"), e.get("variant")): e for e in old_manifest.get("images", [])} +for e in new_manifest.get("images", []): + by_key[(e.get("target"), e.get("variant"))] = e +merged = { + "built": new_manifest.get("built"), + "ninlight_version": new_manifest.get("ninlight_version"), + "images": sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or "")), +} +with open(manifest_path, "w") as fh: + json.dump(merged, fh, indent=2, sort_keys=True) + fh.write("\n") +MERGE + fi fi else echo " kein Schluesselverzeichnis auf dem Entwicklungsrechner - migrate/ auf dem Pi bleibt unangetastet" diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh index 3a9b977..9b5533d 100755 --- a/tools/station/tests/test_deploy.sh +++ b/tools/station/tests/test_deploy.sh @@ -17,6 +17,17 @@ STATION_DIR=$(cd "$HERE/.." && pwd) MKIMAGE_DIR=$(cd "$STATION_DIR/../mkimage" && pwd) REAL_RSYNC=$(command -v rsync) +# Sicherheitsnetz (Fixrunde 1, C4): NINLIGHT_SIGN_DIR ist genau die Variable, mit der +# man auf ~/workspace/fw-sign zeigt - und auf diesem Entwicklungsrechner liegen dort +# ECHTE Signierschluessel. Ist sie im aufrufenden Profil oder fuer einen frueheren +# manuellen Deploy exportiert, darf sie unter keinen Umstaenden in diese Suite +# durchsickern. Deshalb: hier explizit loeschen, und run_deploy() unten liest sie NIE +# wieder - jedes Szenario, das echte Testschluessel braucht, uebergibt sie ueber die +# separate Variable TEST_SIGN_DIR (die deploy.sh selbst gar nicht kennt und die daher +# auch nicht zufaellig aus der Umgebung vorbelegt sein kann). +unset NINLIGHT_SIGN_DIR +unset TEST_SIGN_DIR + TMPROOT=$(mktemp -d) trap 'rm -rf "$TMPROOT"' EXIT @@ -119,18 +130,23 @@ run_deploy() { # 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" - # NINLIGHT_SIGN_DIR IMMER explizit setzen: deploy.sh faellt sonst auf - # $HOME/workspace/fw-sign zurueck, und auf Entwicklungsrechnern liegen dort die - # ECHTEN Signierschluessel. Ein Test darf die niemals auch nur lesen. Szenarien, - # die ein Schluesselverzeichnis brauchen, ueberschreiben die Variable gezielt VOR - # dem Aufruf von run_deploy (siehe unten); dieser Default zeigt auf einen Pfad, - # der in der Sandbox garantiert nicht existiert. + # NINLIGHT_SIGN_DIR wird hier HART auf einen lokalen Wert gesetzt, niemals ueber + # "${NINLIGHT_SIGN_DIR:-default}" aus der geerbten Umgebung uebernommen (das war der + # C4-Fehler aus Fixrunde 1: ein Fallback greift nur, wenn die Variable NICHT gesetzt + # ist - war sie es, z.B. weil ein Profil sie dauerhaft auf ~/workspace/fw-sign zeigen + # laesst, haette der Fallback sie unveraendert durchgereicht). Die einzige Variable, + # die ein Szenario mit echten Testschluesseln setzen darf, ist TEST_SIGN_DIR - eine + # Variable, die deploy.sh selbst gar nicht kennt und die deshalb nie zufaellig von + # aussen vorbelegt sein kann wie NINLIGHT_SIGN_DIR es auf einem Entwicklungsrechner + # ist. Ohne TEST_SIGN_DIR zeigt sie auf einen Pfad, der in der Sandbox garantiert + # nicht existiert. + local sign_dir="${TEST_SIGN_DIR:-$sandbox/no-such-sign-dir}" PATH="$STUBBIN:$PATH" \ NINLIGHT_SSH_BIN="$STUBBIN/ssh" \ 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" \ - NINLIGHT_SIGN_DIR="${NINLIGHT_SIGN_DIR:-$sandbox/no-such-sign-dir}" \ + NINLIGHT_SIGN_DIR="$sign_dir" \ "$sandbox/tools/station/deploy.sh" "$@" } @@ -298,14 +314,13 @@ mkdir -p "$SANDBOX/build/foo" "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc 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_SSH_BIN="$STUBBIN/ssh" \ - 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" \ - NINLIGHT_SIGN_DIR="$SANDBOX/no-such-sign-dir" \ - "$SANDBOX/tools/station/deploy.sh" pi-test foo 2>&1) +# Ueber run_deploy() statt eines eigenen Aufrufs (Fixrunde 1: der vorherige direkte +# Aufruf hier setzte NINLIGHT_SIGN_DIR zwar auch hart, ging aber am gemeinsamen Muster +# vorbei - ein Praezedenzfall, den ein spaeterer Umbau leicht wieder falsch abschreiben +# koennte). FAKE_SYSTEMCTL_* werden als gewoehnliche vorangestellte Umgebungsvariablen +# durchgereicht, run_deploy() ist eine Funktion und sieht sie genauso wie jeder andere +# Befehl. +OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 run_deploy "$SANDBOX" 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 @@ -445,7 +460,7 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" 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 @@ -478,7 +493,7 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) RC=$? set -e MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" @@ -488,6 +503,11 @@ else fail "manifest.json fehlt oder Exit-Code $RC: $OUT" fi set +e +# Form haelt sich an die Spec (docs/superpowers/spec Abschnitt 4) und an das, was +# server.py tatsaechlich liest (Fixrunde 1, C1): "ninlight_version" auf oberster +# Ebene, nicht pro Eintrag - ein reines json.load haette das nicht gemerkt, weil es +# nur die Feldnamen INNERHALB der Eintraege prueft. Die naechste Pruefung (Szenario +# 20) schickt dieselbe Datei zusaetzlich durch den echten Server-Leser. CHECK=$(python3 -c " import json with open('$MANIFEST') as fh: @@ -495,16 +515,20 @@ with open('$MANIFEST') as fh: entries = data.get('images', []) by_variant = {e.get('variant'): e for e in entries if e.get('target') == 'foo'} ok = ( - len(entries) == 2 + isinstance(data.get('built'), str) and len(data['built']) > 0 + and data.get('ninlight_version') == '1.0.0' + and len(entries) == 2 and set(by_variant) == {'prod', 'dev'} + and all(e.get('file') in ('foo_prod.img', 'foo_dev.img') for e in entries) and all(isinstance(e.get('bytes'), int) and e['bytes'] > 0 for e in entries) and all(isinstance(e.get('sha256'), str) and len(e['sha256']) == 64 for e in entries) + and all('ninlight_version' not in e for e in entries) ) -print('OK' if ok else 'FAIL: ' + json.dumps(entries)) +print('OK' if ok else 'FAIL: ' + json.dumps(data)) " 2>&1) set -e if [ "$CHECK" = "OK" ]; then - pass "manifest.json listet target/variant/bytes/sha256 fuer beide Images" + pass "manifest.json traegt built/ninlight_version oben und target/variant/file/bytes/sha256 je Eintrag" else fail "manifest.json-Inhalt unerwartet: $CHECK" fi @@ -550,7 +574,7 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" 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 @@ -583,9 +607,18 @@ echo "bar-ota" >"$SANDBOX/build/bar/ninlight_bar_ota.img" mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" echo "vorhandenes-bar-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" echo "vorhandenes-bar-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_dev.img" +# Manifest eines frueheren Deploys, das bar beschrieben hat - genau das darf ein +# gezielter Deploy von foo nicht einfach wegschreiben (Fixrunde 1, I1). +cat >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" <<'JSON' +{"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.0.1", + "images": [ + {"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x"}, + {"target": "bar", "variant": "dev", "file": "bar_dev.img", "bytes": 19, "sha256": "y"} + ]} +JSON : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" 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 @@ -601,6 +634,21 @@ if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/bar_prod.img" 2> else fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/" fi +set +e +MANIFEST_CHECK=$(python3 -c " +import json +with open('$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json') as fh: + data = json.load(fh) +keys = {(e.get('target'), e.get('variant')) for e in data.get('images', [])} +expected = {('foo', 'prod'), ('foo', 'dev'), ('bar', 'prod'), ('bar', 'dev')} +print('OK' if keys == expected else 'FAIL: ' + json.dumps(sorted(keys))) +" 2>&1) +set -e +if [ "$MANIFEST_CHECK" = "OK" ]; then + pass "manifest.json beschreibt nach dem gezielten Deploy weiterhin BEIDE Geraetetypen (foo neu, bar zusammengefuehrt)" +else + fail "manifest.json verliert Eintraege beim gezielten Deploy: $MANIFEST_CHECK" +fi echo echo "=== Szenario 15: Kein privater Schluessel und kein Schluesselpfad-Inhalt in der Ausgabe (Fall 6) ===" @@ -612,7 +660,7 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" 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 @@ -621,13 +669,26 @@ if echo "$OUT" | grep -q -- "-----BEGIN"; then else pass "kein PEM-Header in der Ausgabe" fi -# Eine Zeile aus der Mitte des privaten Produktionsschluessels darf unter keinen -# Umstaenden in der Ausgabe erscheinen. -KEY_LINE=$(sed -n '3p' "$KEYDIR_FULL/QCX_Device_CA/qcx_firmware_signing_key.pem") -if [ -n "$KEY_LINE" ] && echo "$OUT" | grep -qF "$KEY_LINE"; then - fail "eine Zeile des privaten Produktionsschluessels taucht in der Ausgabe auf" +# ALLE Zeilen BEIDER privater Schluessel pruefen (nicht nur eine einzelne Zeile des +# Produktionsschluessels) und dabei auch das ssh/rsync/sudo-Log durchsuchen, nicht nur +# die Kommandozeilenausgabe von deploy.sh selbst (Fixrunde 1: die vorherige Version +# haette einen Leak ueber eine andere Zeile oder ueber $LOG glatt uebersehen). +LEAK="" +while IFS= read -r line; do + [ -n "$line" ] || continue + if echo "$OUT" | grep -qF -- "$line"; then + LEAK="Ausgabe enthaelt: $line" + break + fi + if grep -qF -- "$line" "$LOG"; then + LEAK="Log enthaelt: $line" + break + fi +done < <(cat "$KEYDIR_FULL/QCX_Device_CA/qcx_firmware_signing_key.pem" "$KEYDIR_FULL/fw_sign.pem") +if [ -z "$LEAK" ]; then + pass "keine Zeile eines privaten Schluessels taucht in Ausgabe oder Log auf" else - pass "keine Schluesselzeile in der Ausgabe" + fail "Schluesselinhalt gefunden - $LEAK" fi echo @@ -640,7 +701,7 @@ make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" A make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img" : >"$LOG" set +e -OUT=$(NINLIGHT_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" 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 @@ -694,6 +755,126 @@ else fail "Ausgabe erwaehnt nicht, dass migrate/ unangetastet bleibt: $OUT" fi +echo +echo "=== Szenario 18 (Fixrunde 1, C2): Schluesselverzeichnis vorhanden, aber leer - migrate/ bleibt unangetastet, Rettungsimage kommt trotzdem ===" +# Der eigentliche C2-Repro: anders als Szenario 12 (Verzeichnis fehlt komplett) EXISTIERT +# $SIGN_DIR hier, enthaelt aber keinen der beiden Schluessel. Vorher fuehrte das trotzdem +# zu einem (leeren) $STAGE/migrate mit Manifest, das dann per --delete ein vorhandenes +# migrate/ auf dem Pi geleert hat. +SIGN_EMPTY="$TMPROOT/keys-empty" +mkdir -p "$SIGN_EMPTY" +SANDBOX="$TMPROOT/s18"; STUBBIN="$TMPROOT/bin18"; LOG="$TMPROOT/s18.log"; SANDBOX_STATE="$TMPROOT/s18-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" +echo "vorhandenes-foo-prod" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" +echo "vorhandenes-foo-dev" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" +echo '{"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.0.1", "images": []}' \ + >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$SIGN_EMPTY" 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_foo_ota.img" ]; then + pass "Rettungsimage (_ota.img) wurde trotzdem ausgeliefert" +else + fail "Rettungsimage fehlt: $OUT" +fi +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "vorhandenes-foo-prod" ] \ + && [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" 2>/dev/null)" = "vorhandenes-foo-dev" ] \ + && grep -q '"images": \[\]' "$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json"; then + pass "vorhandenes migrate/ (Bilder UND manifest.json) blieb unangetastet - Verzeichnis war da, aber leer" +else + fail "migrate/ wurde trotz vorhandenem, aber leerem Schluesselverzeichnis veraendert/geleert - C2 nicht behoben" +fi +if echo "$OUT" | grep -q "enthaelt weder Produktions- noch Entwicklungsschluessel"; then + pass "Meldung nennt das leere Schluesselverzeichnis" +else + fail "keine Meldung ueber das leere Schluesselverzeichnis: $OUT" +fi + +echo +echo "=== Szenario 19 (Fixrunde 1, C2): Voll-Deploy mit nur dev-Schluessel raeumt keine vorhandenen _prod.img weg ===" +SANDBOX="$TMPROOT/s19"; STUBBIN="$TMPROOT/bin19"; LOG="$TMPROOT/s19.log"; SANDBOX_STATE="$TMPROOT/s19-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images/migrate" +echo "echtes-altes-prod-image" >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test 2>&1) # Voll-Deploy, kein Ziel-Argument +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/migrate/foo_dev.img" ]; then + pass "neues dev-Migrationsimage wurde signiert und ausgeliefert" +else + fail "dev-Migrationsimage fehlt: $OUT" +fi +if [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_prod.img" 2>/dev/null)" = "echtes-altes-prod-image" ]; then + pass "das vorhandene _prod.img blieb unangetastet, obwohl dieser Voll-Deploy nur den dev-Schluessel hatte" +else + fail "das vorhandene _prod.img wurde beim Voll-Deploy mit nur dev-Schluessel entfernt - C2 (Teilfall) nicht behoben" +fi + +echo +echo "=== Szenario 20 (Fixrunde 1, C1): manifest.json wird vom ECHTEN Server-Leser korrekt interpretiert ===" +# json.load allein (wie in Szenario 11) haette die falsche Verschachtelung von +# ninlight_version nicht gemerkt, weil es nur die Feldnamen innerhalb der Eintraege +# prueft. Hier laeuft dieselbe Datei stattdessen durch server.py's eigene +# Station.manifest_version() - genau die Funktion, die im echten Betrieb entscheidet, +# welche Version protokolliert und angezeigt wird. +SANDBOX="$TMPROOT/s20"; STUBBIN="$TMPROOT/bin20"; LOG="$TMPROOT/s20.log"; SANDBOX_STATE="$TMPROOT/s20-state" +setup_sandbox "$SANDBOX" +setup_stubbin "$STUBBIN" "$LOG" +mkdir -p "$SANDBOX/build/foo" +make_gz_file "$SANDBOX/build/foo/ninlight_foo_gz.img" +echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img" +: >"$LOG" +set +e +OUT=$(TEST_SIGN_DIR="$KEYDIR_FULL" run_deploy "$SANDBOX" pi-test foo 2>&1) +RC=$? +set -e +MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" +if [ "$RC" -eq 0 ] && [ -f "$MANIFEST" ]; then + pass "manifest.json wurde angelegt" +else + fail "manifest.json fehlt oder Exit-Code $RC: $OUT" +fi +set +e +VERSION=$(python3 -c " +import importlib.util +from pathlib import Path + +station_dir = Path('$STATION_DIR') +spec = importlib.util.spec_from_file_location('ninlight_station_server_c1', station_dir / 'server.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +st = module.Station( + image_dir=Path('$MANIFEST').resolve().parent.parent, + state_path=Path('$TMPROOT/s20-status.json'), + stall_after=None, + mode='migration', + control_path=Path('$TMPROOT/s20-control.json'), +) +print(st.manifest_version()) +" 2>&1) +RC2=$? +set -e +if [ "$RC2" -eq 0 ] && [ "$VERSION" = "1.0.0" ]; then + pass "Station.manifest_version() (der echte Server-Code) liest die Version korrekt, nicht 'unbekannt'" +else + fail "Station.manifest_version() liefert '$VERSION' statt '1.0.0' (RC=$RC2) - Manifest-Form stimmt nicht mit dem Server ueberein" +fi + echo if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden."