fix(station): Manifest-Merge robust gegen Fehlform, Version pro Eintrag

Fixrunde 2 zu "Migrationsimages beim Deploy signieren":

- N1 (Important): das Merge-Skript im SSH-Heredoc von deploy.sh fing beim
  Bestandsmanifest nur Lesefehler und kaputtes JSON ab (OSError/ValueError).
  War die Datei gueltiges JSON mit falscher Form ("[]", "null", ein String,
  oder ein images-Eintrag, der selbst kein Objekt ist), krachte .get() mit
  AttributeError - und das VOR der Installation von
  server.py/products.py/CLI/Unit und vor dem Neustart, liess einen Pi also
  mit neuen Images, aber altem Stationscode zurueck. Jetzt wird alte wie
  neue Manifest-Struktur konsequent auf isinstance(dict)/isinstance(list)
  geprueft, jede Fehlform faellt auf ein leeres Manifest zurueck statt
  abzustuerzen. Neues Szenario 21 deckt alle vier Faelle aus der
  Review-Tabelle ab (kaputt, leer, falsch geformtes JSON, kaputter Eintrag).

- N2 (Important): das zusammengefuehrte Manifest hat "ninlight_version"
  bisher blind vom neuen Manifest uebernommen, obwohl uebernommene
  Alt-Eintraege (deren Images unveraendert liegen bleiben, siehe Fixrunde 1
  I1) eine andere Firmware-Version tragen koennen - eine konkrete falsche
  Version beim Protokollieren ist schlechter als das vorherige "unbekannt".
  Die Version wandert jetzt PRO EINTRAG ins Manifest (deploy.sh erzeugt sie
  dort, der Merge behaelt sie); das manifestweite Feld bleibt nur als
  Bestandskompatibilitaet und wird nur gesetzt, wenn wirklich alle Eintraege
  uebereinstimmen. server.py liest beim Ausliefern jetzt die Version DES
  AUSGELIEFERTEN EINTRAGS (Station.image_version(target, variant) ersetzt
  manifest_version()) und faellt nur auf das manifestweite Feld zurueck,
  wenn der Eintrag selbst keine eigene traegt. test_server.py bekommt einen
  neuen Test fuer den Vorrang des Eintrags vor dem manifestweiten Feld,
  Szenario 14 in test_deploy.sh prueft jetzt zusaetzlich unterschiedliche
  Versionen zwischen zusammengefuehrten Eintraegen und dass das
  manifestweite Feld dann fehlt statt falsch zu sein. Die Spec
  (docs/superpowers/specs/.../design.md, Abschnitt 4, lokal/nicht
  versioniert) ist nachgezogen. Die dadurch ueberfluessig gewordene
  "sorted(versions)[0]"-Stelle aus Fixrunde 1 ist entfallen.

- Kleinigkeit: die Remote-Meldung, wenn migrate/ von diesem Rechner leer
  ist, behauptet nicht mehr "kein Schluesselverzeichnis" (das stimmt im
  Fall "Verzeichnis vorhanden, aber leer" nicht) - sie nennt jetzt keinen
  bestimmten Grund mehr, nur die Konsequenz.
- Kleinigkeit: --exclude=manifest.json ist jetzt --exclude=/manifest.json,
  konsistent mit dem daneben verankerten --exclude=/migrate.

Alle drei Aenderungen (N1, N2, beide Kleinigkeiten) sind vor dem Fix per
Rot-Probe an einer Kopie von deploy.sh verifiziert: mit den urspruenglichen
Fehlern schlagen Szenario 21 (N1) bzw. Szenario 14 (N2) zuverlaessig fehl.

Bewusst nicht angefasst (Review-Vorgabe): der fehlende HOME-Testfall, und
dass der Merge kein os.path.exists() prueft.
This commit is contained in:
Michael Sauer
2026-09-13 20:40:03 +02:00
parent ad2f1b8486
commit 1cb5359754
4 changed files with 218 additions and 52 deletions
+92 -19
View File
@@ -504,10 +504,11 @@ else
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.
# server.py tatsaechlich liest. Seit Fixrunde 2 (N2) traegt "ninlight_version" PRO
# EINTRAG die Version - hier stimmen beide Eintraege ueberein, deshalb steht das
# manifestweite Feld zusaetzlich als Bequemlichkeit oben. Ein reines json.load haette
# eine falsche Verschachtelung nicht gemerkt (Fixrunde 1, C1); die naechste Pruefung
# (Szenario 20) schickt dieselbe Datei zusaetzlich durch den echten Server-Leser.
CHECK=$(python3 -c "
import json
with open('$MANIFEST') as fh:
@@ -522,13 +523,13 @@ ok = (
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)
and all(e.get('ninlight_version') == '1.0.0' for e in entries)
)
print('OK' if ok else 'FAIL: ' + json.dumps(data))
" 2>&1)
set -e
if [ "$CHECK" = "OK" ]; then
pass "manifest.json traegt built/ninlight_version oben und target/variant/file/bytes/sha256 je Eintrag"
pass "manifest.json traegt built/ninlight_version oben (Bequemlichkeit) und target/variant/file/bytes/sha256/ninlight_version je Eintrag"
else
fail "manifest.json-Inhalt unerwartet: $CHECK"
fi
@@ -608,12 +609,17 @@ 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).
# gezielter Deploy von foo nicht einfach wegschreiben (Fixrunde 1, I1). bar traegt
# absichtlich eine ANDERE Firmware-Version als das, was dieser Lauf fuer foo signiert
# (nl_mkimage-Stub liefert immer "1.0.0") - das Manifest nach dem Merge muss beide
# Versionen unveraendert nebeneinander stehen lassen (Fixrunde 2, N2).
cat >"$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json" <<'JSON'
{"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.0.1",
{"built": "2026-01-01T00:00:00Z", "ninlight_version": "0.9.0",
"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"}
{"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x",
"ninlight_version": "0.9.0"},
{"target": "bar", "variant": "dev", "file": "bar_dev.img", "bytes": 19, "sha256": "y",
"ninlight_version": "0.9.0"}
]}
JSON
: >"$LOG"
@@ -639,15 +645,33 @@ 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', [])}
entries = data.get('images', [])
keys = {(e.get('target'), e.get('variant')) for e in entries}
expected = {('foo', 'prod'), ('foo', 'dev'), ('bar', 'prod'), ('bar', 'dev')}
print('OK' if keys == expected else 'FAIL: ' + json.dumps(sorted(keys)))
by_key = {(e.get('target'), e.get('variant')): e for e in entries}
ok = (
keys == expected
# foo wurde in DIESEM Lauf signiert -> Stub-Version 1.0.0 (Fixrunde 2, N2: Version
# steht pro Eintrag).
and by_key[('foo', 'prod')].get('ninlight_version') == '1.0.0'
and by_key[('foo', 'dev')].get('ninlight_version') == '1.0.0'
# bar wurde NICHT neu signiert -> seine alte Version (0.9.0) bleibt unangetastet,
# auch wenn sie von foos Version abweicht.
and by_key[('bar', 'prod')].get('ninlight_version') == '0.9.0'
and by_key[('bar', 'dev')].get('ninlight_version') == '0.9.0'
# Da foo (1.0.0) und bar (0.9.0) nach dem Merge NICHT uebereinstimmen, darf das
# manifestweite Feld nicht mehr die Version dieses Laufs behaupten - es muss ganz
# fehlen (Fixrunde 2, N2: das war der eigentliche Fehler - vorher stand hier
# weiterhin 1.0.0, obwohl bars Bilder unveraendert auf 0.9.0 blieben).
and 'ninlight_version' not in data
)
print('OK' if ok else 'FAIL: ' + json.dumps(data))
" 2>&1)
set -e
if [ "$MANIFEST_CHECK" = "OK" ]; then
pass "manifest.json beschreibt nach dem gezielten Deploy weiterhin BEIDE Geraetetypen (foo neu, bar zusammengefuehrt)"
pass "manifest.json beschreibt nach dem gezielten Deploy weiterhin BEIDE Geraetetypen mit je eigener Version, ohne falsches manifestweites Feld"
else
fail "manifest.json verliert Eintraege beim gezielten Deploy: $MANIFEST_CHECK"
fail "manifest.json-Inhalt nach dem gezielten Deploy unerwartet: $MANIFEST_CHECK"
fi
echo
@@ -829,8 +853,10 @@ echo "=== Szenario 20 (Fixrunde 1, C1): manifest.json wird vom ECHTEN Server-Les
# 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.
# Station.image_version(target, variant) - genau die Funktion, die im echten Betrieb
# entscheidet, welche Version protokolliert und angezeigt wird (seit Fixrunde 2, N2:
# pro ausgeliefertem Eintrag, nicht mehr nur manifestweit - deshalb jetzt mit
# target/variant statt ohne Argumente).
SANDBOX="$TMPROOT/s20"; STUBBIN="$TMPROOT/bin20"; LOG="$TMPROOT/s20.log"; SANDBOX_STATE="$TMPROOT/s20-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
@@ -865,16 +891,63 @@ st = module.Station(
mode='migration',
control_path=Path('$TMPROOT/s20-control.json'),
)
print(st.manifest_version())
print(st.image_version('foo', 'dev'))
" 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'"
pass "Station.image_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"
fail "Station.image_version() liefert '$VERSION' statt '1.0.0' (RC=$RC2) - Manifest-Form stimmt nicht mit dem Server ueberein"
fi
echo
echo "=== Szenario 21 (Fixrunde 2, N1): kaputtes/falsch geformtes Bestandsmanifest darf den Deploy nicht mitten drin abbrechen ==="
# Der Merge steht in deploy.sh 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. "truncated" und "empty" liefen schon vorher durch (json.load
# wirft ValueError, das war immer abgefangen); "array"/"null"/"string"/"null-entry"
# sind gueltiges JSON mit falscher FORM und haben vorher mit AttributeError abgebrochen
# ("... has no attribute 'get'"), weil nur auf Lese-, nicht auf Parse-Fehler geprueft
# wurde. Alle vier Zeilen aus der Review-Tabelle.
mkdir -p "$TMPROOT/bad-manifests"
printf '%s' '{ kaputt' >"$TMPROOT/bad-manifests/truncated.json"
: >"$TMPROOT/bad-manifests/empty.json"
printf '%s' '[]' >"$TMPROOT/bad-manifests/array.json"
printf '%s' 'null' >"$TMPROOT/bad-manifests/null.json"
printf '%s' '"text"' >"$TMPROOT/bad-manifests/string.json"
printf '%s' '{"images": [null]}' >"$TMPROOT/bad-manifests/null-entry.json"
for label in truncated empty array null string null-entry; do
SANDBOX="$TMPROOT/s21-$label"; STUBBIN="$TMPROOT/bin21-$label"
LOG="$TMPROOT/s21-$label.log"; SANDBOX_STATE="$TMPROOT/s21-$label-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"
cp "$TMPROOT/bad-manifests/$label.json" "$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json"
: >"$LOG"
set +e
# Nur EIN Schluessel (dev) -> MIGRATE_COMPLETE=0 -> der Merge-Zweig laeuft, nicht der
# --delete-Zweig, der das kaputte Bestandsmanifest ohnehin ueberschreiben wuerde.
OUT=$(TEST_SIGN_DIR="$KEYDIR_DEV_ONLY" run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then
pass "Bestandsmanifest '$label': Exit-Code 0, Deploy bricht nicht mitten drin ab"
else
fail "Bestandsmanifest '$label': Exit-Code $RC - Deploy abgebrochen. Ausgabe: $OUT"
fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/server.py" ] \
&& [ -f "$SANDBOX_STATE/opt/ninlight-station/images/migrate/foo_dev.img" ]; then
pass "Bestandsmanifest '$label': Deploy lief vollstaendig durch (server.py installiert, foo_dev.img da)"
else
fail "Bestandsmanifest '$label': Deploy blieb auf halbem Weg stehen (server.py oder foo_dev.img fehlt)"
fi
done
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
+29
View File
@@ -686,6 +686,9 @@ def test_range_resume_on_migration_image(migration_server):
def test_version_comes_from_manifest(migration_server):
"""Faellt auf das manifestweite Feld zurueck, wenn der Eintrag selbst keine eigene
Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor
Fixrunde 2 (N2)."""
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
@@ -697,6 +700,32 @@ def test_version_comes_from_manifest(migration_server):
assert st["recent"][-1]["version"] == "0.3.9"
def test_version_prefers_the_delivered_entry_over_the_manifest_wide_field(migration_server):
"""Fixrunde 2, N2: nach einem Merge in deploy.sh (Voll-Deploy mit nur einem der
beiden Signierschluessel) koennen Eintraege im selben Manifest unterschiedliche
Firmware-Versionen tragen - z.B. ein neu signiertes g95_cw_dev.img auf 1.2.0, aber
ein unveraendert liegen gebliebenes aelteres g95_cw_prod.img auf 1.0.0. Der Server
darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die
Version DES AUSGELIEFERTEN Eintrags nehmen."""
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG-DEV")
(imgdir / "migrate" / "g95_cw_prod.img").write_bytes(b"IMG-PROD")
(imgdir / "migrate" / "manifest.json").write_text(json.dumps({
# Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen
# darauf im Test auffallen wuerde.
"ninlight_version": "1.2.0",
"images": [
{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
"ninlight_version": "1.2.0"},
{"target": "g95_cw", "variant": "prod", "file": "g95_cw_prod.img",
"ninlight_version": "1.0.0"},
]}))
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img")
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["version"] == "1.0.0"
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
"""Dieselbe 0600-Falle wie bei status.json (siehe
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer