fix(station): Manifest-Form, Teilschluessel-Faelle und Schluessel-Testisolation

Fixrunde 1 zu "Migrationsimages beim Deploy signieren":

- manifest.json traegt ninlight_version und ein neues built jetzt auf oberster
  Ebene statt pro Eintrag, dazu "file" statt der Redundanz einer Version pro
  Bild - genau die Form aus der Spec (Abschnitt 4) und aus server.py's
  Station.manifest_version(), die vorher immer "unbekannt" gelesen hat, weil
  sie ausschliesslich auf oberster Ebene sucht (C1). Ein Testfall schickt das
  Manifest jetzt zusaetzlich durch genau diese Funktion statt nur durch
  json.load.

- Ein Schluesselverzeichnis, das existiert, aber weder Produktions- noch
  Entwicklungsschluessel enthaelt, legt kein STAGE/migrate mehr an - das
  vorherige Verhalten liess dafuer trotzdem ein (leeres) migrate/ mit
  Manifest entstehen, das der volle rsync-Abgleich dann mit --delete gegen
  ein vorhandenes migrate/ auf dem Pi durchgesetzt und es geleert hat (C2).
  Aus demselben Grund war die _gz.img-Pflichtpruefung faelschlich
  unconditional: fehlte sie, brach sogar ein Deploy ab, der gar nicht
  signieren wollte, und nahm die Rettungsimages gleich mit (C3).

- Ein neues MIGRATE_COMPLETE-Flag (nur 1, wenn dieser Lauf mit BEIDEN
  Schluesseln signiert hat) entscheidet jetzt, ob der Abgleich auf dem Pi
  --delete benutzen und das Manifest ersetzen darf. Bei nur einem der beiden
  Schluessel oder einem gezielten Deploy synct migrate/ ohne --delete und
  merged das Manifest serverseitig mit dem vorhandenen zusammen, statt es zu
  ueberschreiben - vorher raeumte ein Voll-Deploy mit nur dem dev-Schluessel
  saemtliche _prod.img weg (C2, Teilfall), und ein gezielter Deploy liess ein
  Manifest zurueck, das die unveraendert gebliebenen Ziele nicht mehr
  erwaehnte (I1).

- ${HOME:-} statt $HOME beim Ermitteln von SIGN_DIR, damit ein Aufruf unter
  `set -u` ohne gesetztes HOME (cron, env -i) nicht unnoetig abbricht, obwohl
  nur die Rettungsimages gebraucht werden.

- tests/test_deploy.sh: die Sicherheitsabsicherung aus dem letzten Durchgang
  hatte selbst eine Luecke (C4) - "${NINLIGHT_SIGN_DIR:-default}" in
  run_deploy() greift nur, wenn die Variable NICHT gesetzt ist, und genau die
  ist es, mit der man auf ~/workspace/fw-sign (das ECHTE Schluesselverzeichnis
  auf diesem Rechner) zeigt. run_deploy() setzt NINLIGHT_SIGN_DIR jetzt hart
  und liest dafuer nur noch die eigene Testvariable TEST_SIGN_DIR, die
  deploy.sh nicht kennt und die deshalb nie von aussen vorbelegt sein kann;
  am Dateikopf werden zur Sicherheit beide Variablen ge-unset. Verifiziert:
  die Suite bleibt gruen, wenn NINLIGHT_SIGN_DIR vorher exportiert ist wie in
  einem echten Entwicklerprofil.

- Szenario 15 vergleicht jetzt ALLE Zeilen beider privater Testschluessel
  gegen Ausgabe UND Log, nicht nur eine einzelne Zeile des Produktions-
  schluessels gegen die Ausgabe.

Vier neue/erweiterte Testfaelle (18-20) plus die Ergaenzung an Szenario 14:
Verzeichnis vorhanden aber leer, Voll-Deploy mit nur dev-Schluessel, echter
Server-Leser fuer das Manifest, gezielter Deploy fuehrt das Manifest zusammen
statt es zu verkuerzen. Jeder der vier Critical-Befunde wurde vor dem Fix per
Rot-Probe am kaputten Code bestaetigt.
This commit is contained in:
Michael Sauer
2026-09-13 20:14:19 +02:00
parent 59c8c150bd
commit ad2f1b8486
2 changed files with 328 additions and 66 deletions
+96 -15
View File
@@ -72,13 +72,21 @@ echo "== Migrationsimages signieren =="
# Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige # Fehlt das Verzeichnis oder ein einzelner Schluessel, entfaellt die jeweilige
# Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem # Migrationsvariante mit einer Meldung - die Rettungsimages (_ota.img) werden trotzdem
# ausgeliefert (Exit-Code bleibt 0). # 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_PROD="$SIGN_DIR/QCX_Device_CA/qcx_firmware_signing_key.pem"
KEY_DEV="$SIGN_DIR/fw_sign.pem" KEY_DEV="$SIGN_DIR/fw_sign.pem"
NL_SIGN="$ROOT/tools/mkimage/nl_sign.py" 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 if [ -d "$SIGN_DIR" ]; then
mkdir -p "$STAGE/migrate"
HAVE_PROD=0 HAVE_PROD=0
HAVE_DEV=0 HAVE_DEV=0
if [ -f "$KEY_PROD" ]; then if [ -f "$KEY_PROD" ]; then
@@ -91,12 +99,20 @@ if [ -d "$SIGN_DIR" ]; then
else else
echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2 echo "Hinweis: Entwicklungsschluessel fehlt - keine _dev.img-Migrationsimages" >&2
fi 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 for f in "${FILES[@]}"; do
base=$(basename "$f") base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img} target=${base#ninlight_}; target=${target%_ota.img}
# Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide belegen # Signiert wird IMMER das rohe _gz.img, niemals das fertige _ota.img: beide
# denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein signiertes # belegen denselben 512-Byte-Block am Dateiende (Trailer bzw. Signatur), ein
# _ota.img waere ein Image, das die alte Lampe ablehnt oder halb annimmt. # signiertes _ota.img waere ein Image, das die alte Lampe ablehnt oder halb
# annimmt.
gz="$ROOT/build/$target/ninlight_${target}_gz.img" gz="$ROOT/build/$target/ninlight_${target}_gz.img"
[ -f "$gz" ] || { [ -f "$gz" ] || {
echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; } echo "Fehler: $gz fehlt - kein Migrationsimage fuer $target moeglich" >&2; exit 1; }
@@ -109,18 +125,25 @@ if [ -d "$SIGN_DIR" ]; then
|| { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; } || { echo "Fehler: Signieren von $target (dev) fehlgeschlagen" >&2; exit 1; }
fi fi
done done
if [ "$HAVE_PROD" -eq 1 ] && [ "$HAVE_DEV" -eq 1 ]; then
MIGRATE_COMPLETE=1
fi
VERSION_ARGS=() VERSION_ARGS=()
for t in "${!NINL_VERSION[@]}"; do for t in "${!NINL_VERSION[@]}"; do
VERSION_ARGS+=("$t=${NINL_VERSION[$t]}") VERSION_ARGS+=("$t=${NINL_VERSION[$t]}")
done done
# manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu # manifest.json aus den entstandenen Dateien - Groesse und sha256 werden hier neu
# berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit # berechnet statt aus dem Signierschritt uebernommen, damit die Datei die Wahrheit
# ueber das tatsaechlich abgelegte Image sagt. # 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' python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY'
import hashlib import hashlib
import json import json
import os import os
import sys import sys
from datetime import datetime, timezone
migrate_dir = sys.argv[1] migrate_dir = sys.argv[1]
version_map = dict(a.split("=", 1) for a in sys.argv[2:]) 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({ entries.append({
"target": target, "target": target,
"variant": variant, "variant": variant,
"file": name,
"bytes": len(data), "bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(), "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: 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") fh.write("\n")
PY PY
else
echo "Hinweis: $SIGN_DIR enthaelt weder Produktions- noch Entwicklungsschluessel - keine Migrationsimages erzeugt" >&2
fi
else else
echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2 echo "Hinweis: Schluesselverzeichnis $SIGN_DIR nicht gefunden - keine Migrationsimages erzeugt" >&2
fi 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/" 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 # Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. # 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 set -e
FULL_SYNC=$1 FULL_SYNC=$1
MIGRATE_COMPLETE=$2
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station} ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station} BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service} 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/" sudo rsync -a -c --exclude=/migrate /tmp/ninlight-images/ "$ROOT_DIR/images/"
fi fi
# Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf # Migrationsimages getrennt behandeln: fehlt migrate/ in der Uebertragung (weil auf
# dem Entwicklungsrechner kein Schluesselverzeichnis vorhanden war), darf ein # dem Entwicklungsrechner kein Schluesselverzeichnis oder gar kein Schluessel
# vorhandenes migrate/ auf dem Pi NICHT geleert werden - sonst zerstoert ein Deploy # vorhanden war), darf ein vorhandenes migrate/ auf dem Pi NICHT geleert werden -
# ohne Schluesselzugriff die Bestueckung frueherer Deploys (derselbe Fehler wie # sonst zerstoert ein Deploy ohne Schluesselzugriff die Bestueckung frueherer Deploys
# frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen statt beim Ersetzen). # (derselbe Fehler wie frueher bei den _ota.img, siehe oben, nur diesmal beim Fehlen
# statt beim Ersetzen).
if [ -d /tmp/ninlight-images/migrate ]; then if [ -d /tmp/ninlight-images/migrate ]; then
sudo install -d -m 0755 "$ROOT_DIR/images/migrate" 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/" sudo rsync -a -c --delete /tmp/ninlight-images/migrate/ "$ROOT_DIR/images/migrate/"
else 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 fi
else else
echo " kein Schluesselverzeichnis auf dem Entwicklungsrechner - migrate/ auf dem Pi bleibt unangetastet" echo " kein Schluesselverzeichnis auf dem Entwicklungsrechner - migrate/ auf dem Pi bleibt unangetastet"
+211 -30
View File
@@ -17,6 +17,17 @@ STATION_DIR=$(cd "$HERE/.." && pwd)
MKIMAGE_DIR=$(cd "$STATION_DIR/../mkimage" && pwd) MKIMAGE_DIR=$(cd "$STATION_DIR/../mkimage" && pwd)
REAL_RSYNC=$(command -v rsync) 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) TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT trap 'rm -rf "$TMPROOT"' EXIT
@@ -119,18 +130,23 @@ run_deploy() {
# Auf dem echten Pi existieren /usr/local/bin und /etc/systemd/system schon; # Auf dem echten Pi existieren /usr/local/bin und /etc/systemd/system schon;
# in der Sandbox muessen sie fuer "install" eigens angelegt werden. # in der Sandbox muessen sie fuer "install" eigens angelegt werden.
mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system" mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
# NINLIGHT_SIGN_DIR IMMER explizit setzen: deploy.sh faellt sonst auf # NINLIGHT_SIGN_DIR wird hier HART auf einen lokalen Wert gesetzt, niemals ueber
# $HOME/workspace/fw-sign zurueck, und auf Entwicklungsrechnern liegen dort die # "${NINLIGHT_SIGN_DIR:-default}" aus der geerbten Umgebung uebernommen (das war der
# ECHTEN Signierschluessel. Ein Test darf die niemals auch nur lesen. Szenarien, # C4-Fehler aus Fixrunde 1: ein Fallback greift nur, wenn die Variable NICHT gesetzt
# die ein Schluesselverzeichnis brauchen, ueberschreiben die Variable gezielt VOR # ist - war sie es, z.B. weil ein Profil sie dauerhaft auf ~/workspace/fw-sign zeigen
# dem Aufruf von run_deploy (siehe unten); dieser Default zeigt auf einen Pfad, # laesst, haette der Fallback sie unveraendert durchgereicht). Die einzige Variable,
# der in der Sandbox garantiert nicht existiert. # 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" \ PATH="$STUBBIN:$PATH" \
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \ NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \ NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \ NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \ 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" "$@" "$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" echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e set +e
OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \ # Ueber run_deploy() statt eines eigenen Aufrufs (Fixrunde 1: der vorherige direkte
PATH="$STUBBIN:$PATH" \ # Aufruf hier setzte NINLIGHT_SIGN_DIR zwar auch hart, ging aber am gemeinsamen Muster
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \ # vorbei - ein Praezedenzfall, den ein spaeterer Umbau leicht wieder falsch abschreiben
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \ # koennte). FAKE_SYSTEMCTL_* werden als gewoehnliche vorangestellte Umgebungsvariablen
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \ # durchgereicht, run_deploy() ist eine Funktion und sieht sie genauso wie jeder andere
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \ # Befehl.
NINLIGHT_SIGN_DIR="$SANDBOX/no-such-sign-dir" \ OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 run_deploy "$SANDBOX" pi-test foo 2>&1)
"$SANDBOX/tools/station/deploy.sh" pi-test foo 2>&1)
RC=$? RC=$?
set -e 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 [ "$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" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" MANIFEST="$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json"
@@ -488,6 +503,11 @@ else
fail "manifest.json fehlt oder Exit-Code $RC: $OUT" fail "manifest.json fehlt oder Exit-Code $RC: $OUT"
fi fi
set +e 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 " CHECK=$(python3 -c "
import json import json
with open('$MANIFEST') as fh: with open('$MANIFEST') as fh:
@@ -495,16 +515,20 @@ with open('$MANIFEST') as fh:
entries = data.get('images', []) entries = data.get('images', [])
by_variant = {e.get('variant'): e for e in entries if e.get('target') == 'foo'} by_variant = {e.get('variant'): e for e in entries if e.get('target') == 'foo'}
ok = ( 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 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('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(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) " 2>&1)
set -e set -e
if [ "$CHECK" = "OK" ]; then 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 else
fail "manifest.json-Inhalt unerwartet: $CHECK" fail "manifest.json-Inhalt unerwartet: $CHECK"
fi 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" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" 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-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" 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" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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 else
fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/" fail "Migrationsimages von bar wurden veraendert oder geloescht - 4a-Fehler fuer migrate/"
fi 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
echo "=== Szenario 15: Kein privater Schluessel und kein Schluesselpfad-Inhalt in der Ausgabe (Fall 6) ===" 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" echo "foo-ota" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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 else
pass "kein PEM-Header in der Ausgabe" pass "kein PEM-Header in der Ausgabe"
fi fi
# Eine Zeile aus der Mitte des privaten Produktionsschluessels darf unter keinen # ALLE Zeilen BEIDER privater Schluessel pruefen (nicht nur eine einzelne Zeile des
# Umstaenden in der Ausgabe erscheinen. # Produktionsschluessels) und dabei auch das ssh/rsync/sudo-Log durchsuchen, nicht nur
KEY_LINE=$(sed -n '3p' "$KEYDIR_FULL/QCX_Device_CA/qcx_firmware_signing_key.pem") # die Kommandozeilenausgabe von deploy.sh selbst (Fixrunde 1: die vorherige Version
if [ -n "$KEY_LINE" ] && echo "$OUT" | grep -qF "$KEY_LINE"; then # haette einen Leak ueber eine andere Zeile oder ueber $LOG glatt uebersehen).
fail "eine Zeile des privaten Produktionsschluessels taucht in der Ausgabe auf" 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 else
pass "keine Schluesselzeile in der Ausgabe" fail "Schluesselinhalt gefunden - $LEAK"
fi fi
echo 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" make_ota_file "$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG" : >"$LOG"
set +e 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=$? RC=$?
set -e set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi 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" fail "Ausgabe erwaehnt nicht, dass migrate/ unangetastet bleibt: $OUT"
fi 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 echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."