From 1cb5359754c01fca87cf8197d74ac4a8b7fb994c Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 20:40:03 +0200 Subject: [PATCH] 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. --- tools/station/deploy.sh | 74 +++++++++++++------ tools/station/server.py | 56 +++++++++++---- tools/station/tests/test_deploy.sh | 111 ++++++++++++++++++++++++----- tools/station/tests/test_server.py | 29 ++++++++ 4 files changed, 218 insertions(+), 52 deletions(-) diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index 12892a5..3c7b231 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -136,8 +136,12 @@ if [ -d "$SIGN_DIR" ]; then # 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"). + # 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 @@ -163,23 +167,18 @@ for name in sorted(os.listdir(migrate_dir)): "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, } +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") @@ -266,7 +265,7 @@ if [ -d /tmp/ninlight-images/migrate ]; then # 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/" + 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 @@ -275,19 +274,48 @@ 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 = {"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 + 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"), - "ninlight_version": new_manifest.get("ninlight_version"), - "images": sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or "")), + "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") @@ -295,7 +323,11 @@ MERGE fi fi else - echo " kein Schluesselverzeichnis auf dem Entwicklungsrechner - migrate/ auf dem Pi bleibt unangetastet" + # 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" diff --git a/tools/station/server.py b/tools/station/server.py index a1439d2..f9658fb 100755 --- a/tools/station/server.py +++ b/tools/station/server.py @@ -77,7 +77,11 @@ class Station: # wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2). self.last_seen: dict | None = None self._manifest_mtime: float | None = None - self._manifest_version = "unbekannt" + # Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch + # geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt + # jeder Eintrag seine eigene Version, image_version() muss also den ganzen + # Eintragssatz vorhalten, nicht nur einen String. + self._manifest_cache: dict = {} # Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder # schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht # dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf @@ -104,26 +108,54 @@ class Station: def migrate_image_path(self, target: str, variant: str) -> Path: return self.image_dir / "migrate" / f"{target}_{variant}.img" - def manifest_version(self) -> str: - """Version aus /migrate/manifest.json, neu eingelesen bei jeder - Aenderung der Datei (mtime-Vergleich). Fehlt sie oder ist sie kaputt, ist die - Version "unbekannt" - die Migrationsimages selbst tragen keinen ninlight-Trailer, - aus dem der Server sie lesen koennte (Spec Abschnitt 4).""" + def _load_manifest(self) -> dict: + """Geparster Inhalt von /migrate/manifest.json, neu eingelesen bei + jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie unlesbar/kaputtes + JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis {} - dieselbe + Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit eine falsch + geformte, aber syntaktisch gueltige Datei nicht mit einem AttributeError den + Server abschiesst.""" path = self.image_dir / "migrate" / "manifest.json" try: mtime = path.stat().st_mtime except OSError: self._manifest_mtime = None - self._manifest_version = "unbekannt" - return self._manifest_version + self._manifest_cache = {} + return self._manifest_cache if mtime != self._manifest_mtime: try: data = json.loads(path.read_text()) - self._manifest_version = data.get("ninlight_version", "unbekannt") except (OSError, ValueError): - self._manifest_version = "unbekannt" + data = {} + if not isinstance(data, dict): + data = {} + self._manifest_cache = data self._manifest_mtime = mtime - return self._manifest_version + return self._manifest_cache + + def image_version(self, target: str, variant: str) -> str: + """Firmware-Version fuer genau das Migrationsimage, das gerade ausgeliefert + wird - die Migrationsimages selbst tragen keinen ninlight-Trailer, aus dem der + Server sie lesen koennte (Spec Abschnitt 4). Bevorzugt die Version aus dem + passenden Eintrag in manifest.json; nur wenn der Eintrag keine eigene traegt + (Bestandskompatibilitaet mit einem manifestweiten Feld), faellt es auf das + Feld auf oberster Ebene zurueck. Ein einzelnes manifestweites Feld allein + waere seit dem Zusammenfuehren mehrerer Deploys (deploy.sh, Fixrunde 1 I1) + schlicht nicht mehr wahr: uebernommene Alt-Eintraege koennen eine andere + Version tragen als die gerade neu signierten (Fixrunde 2, N2).""" + data = self._load_manifest() + images = data.get("images") + if isinstance(images, list): + for entry in images: + if not isinstance(entry, dict): + continue + if entry.get("target") == target and entry.get("variant") == variant: + version = entry.get("ninlight_version") + if isinstance(version, str) and version: + return version + break + top = data.get("ninlight_version") + return top if isinstance(top, str) and top else "unbekannt" def read_control(self): """Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" - @@ -373,7 +405,7 @@ class Handler(BaseHTTPRequestHandler): return target, variant, name = product.target, product.variant, product.name - version = self.station.manifest_version() + version = self.station.image_version(target, variant) # claim_delivery() liest die Phase UND verbraucht eine armed-Freigabe atomar unter # station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine # zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst. diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh index 9b5533d..6e80ae4 100755 --- a/tools/station/tests/test_deploy.sh +++ b/tools/station/tests/test_deploy.sh @@ -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." diff --git a/tools/station/tests/test_server.py b/tools/station/tests/test_server.py index 8f5c777..18aa001 100644 --- a/tools/station/tests/test_server.py +++ b/tools/station/tests/test_server.py @@ -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