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
+53 -21
View File
@@ -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"
+44 -12
View File
@@ -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 <image_dir>/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 <image_dir>/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.
+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