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:
+53
-21
@@ -136,8 +136,12 @@ if [ -d "$SIGN_DIR" ]; then
|
|||||||
# 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. Form haelt sich an die Spec
|
# 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
|
# (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 -
|
# liest. Fixrunde 2, N2: "ninlight_version" steht PRO EINTRAG, nicht (mehr nur)
|
||||||
# der Server las vorher immer "unbekannt").
|
# 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'
|
python3 - "$STAGE/migrate" "${VERSION_ARGS[@]}" <<'PY'
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -163,23 +167,18 @@ for name in sorted(os.listdir(migrate_dir)):
|
|||||||
"file": name,
|
"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 = {
|
manifest = {
|
||||||
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"built": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"ninlight_version": ninlight_version,
|
|
||||||
"images": entries,
|
"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:
|
with open(os.path.join(migrate_dir, "manifest.json"), "w") as fh:
|
||||||
json.dump(manifest, fh, indent=2, sort_keys=True)
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||||
fh.write("\n")
|
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
|
# sondern mit dem, was schon da ist, zusammengefuehrt (Fixrunde 1, I1 - sonst
|
||||||
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
# verschwinden die Eintraege aller Ziele/Varianten, die dieser Lauf nicht neu
|
||||||
# gebaut hat, obwohl ihre Images unveraendert liegen bleiben).
|
# 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
|
if [ -f /tmp/ninlight-images/migrate/manifest.json ]; then
|
||||||
sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/manifest.json <<'MERGE'
|
sudo python3 - "$ROOT_DIR/images/migrate/manifest.json" /tmp/ninlight-images/migrate/manifest.json <<'MERGE'
|
||||||
import json
|
import json
|
||||||
@@ -275,19 +274,48 @@ import sys
|
|||||||
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
manifest_path, new_manifest_path = sys.argv[1], sys.argv[2]
|
||||||
with open(new_manifest_path) as fh:
|
with open(new_manifest_path) as fh:
|
||||||
new_manifest = json.load(fh)
|
new_manifest = json.load(fh)
|
||||||
|
if not isinstance(new_manifest, dict):
|
||||||
|
new_manifest = {}
|
||||||
try:
|
try:
|
||||||
with open(manifest_path) as fh:
|
with open(manifest_path) as fh:
|
||||||
old_manifest = json.load(fh)
|
old_manifest = json.load(fh)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
old_manifest = {"images": []}
|
old_manifest = {}
|
||||||
by_key = {(e.get("target"), e.get("variant")): e for e in old_manifest.get("images", [])}
|
# Fixrunde 2, N1: ein BESTEHENDES manifest.json kann gueltiges JSON sein und trotzdem
|
||||||
for e in new_manifest.get("images", []):
|
# die falsche Form haben (z.B. "[]", "null", ein String, oder ein Eintrag, der selbst
|
||||||
by_key[(e.get("target"), e.get("variant"))] = e
|
# 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 = {
|
merged = {
|
||||||
"built": new_manifest.get("built"),
|
"built": new_manifest.get("built"),
|
||||||
"ninlight_version": new_manifest.get("ninlight_version"),
|
"images": merged_images,
|
||||||
"images": sorted(by_key.values(), key=lambda e: (e.get("target") or "", e.get("variant") or "")),
|
|
||||||
}
|
}
|
||||||
|
# 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:
|
with open(manifest_path, "w") as fh:
|
||||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
@@ -295,7 +323,11 @@ MERGE
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
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
|
fi
|
||||||
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
|
||||||
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
sudo install -m 0644 /tmp/products.py "$ROOT_DIR/products.py"
|
||||||
|
|||||||
+44
-12
@@ -77,7 +77,11 @@ class Station:
|
|||||||
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
|
||||||
self.last_seen: dict | None = None
|
self.last_seen: dict | None = None
|
||||||
self._manifest_mtime: float | 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
|
# Wird True, wenn write_control() zuletzt fehlgeschlagen ist (z.B. volles oder
|
||||||
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
# schreibgeschuetztes /run) - claim_delivery() liest das, um "armed" nicht
|
||||||
# dauerhaft scharf zu lassen, nur weil das Verbrauchen der Freigabe nicht auf
|
# 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:
|
def migrate_image_path(self, target: str, variant: str) -> Path:
|
||||||
return self.image_dir / "migrate" / f"{target}_{variant}.img"
|
return self.image_dir / "migrate" / f"{target}_{variant}.img"
|
||||||
|
|
||||||
def manifest_version(self) -> str:
|
def _load_manifest(self) -> dict:
|
||||||
"""Version aus <image_dir>/migrate/manifest.json, neu eingelesen bei jeder
|
"""Geparster Inhalt von <image_dir>/migrate/manifest.json, neu eingelesen bei
|
||||||
Aenderung der Datei (mtime-Vergleich). Fehlt sie oder ist sie kaputt, ist die
|
jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie unlesbar/kaputtes
|
||||||
Version "unbekannt" - die Migrationsimages selbst tragen keinen ninlight-Trailer,
|
JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis {} - dieselbe
|
||||||
aus dem der Server sie lesen koennte (Spec Abschnitt 4)."""
|
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"
|
path = self.image_dir / "migrate" / "manifest.json"
|
||||||
try:
|
try:
|
||||||
mtime = path.stat().st_mtime
|
mtime = path.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
self._manifest_mtime = None
|
self._manifest_mtime = None
|
||||||
self._manifest_version = "unbekannt"
|
self._manifest_cache = {}
|
||||||
return self._manifest_version
|
return self._manifest_cache
|
||||||
if mtime != self._manifest_mtime:
|
if mtime != self._manifest_mtime:
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text())
|
data = json.loads(path.read_text())
|
||||||
self._manifest_version = data.get("ninlight_version", "unbekannt")
|
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
self._manifest_version = "unbekannt"
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
self._manifest_cache = data
|
||||||
self._manifest_mtime = mtime
|
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):
|
def read_control(self):
|
||||||
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
|
"""Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" -
|
||||||
@@ -373,7 +405,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
target, variant, name = product.target, product.variant, product.name
|
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
|
# 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
|
# station.lock (Fixrunde 1, C1) - zwischen dem Lesen und dem Verbrauchen darf keine
|
||||||
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
# zweite gleichzeitige Anfrage hindurchschluepfen, siehe die Methode selbst.
|
||||||
|
|||||||
@@ -504,10 +504,11 @@ else
|
|||||||
fi
|
fi
|
||||||
set +e
|
set +e
|
||||||
# Form haelt sich an die Spec (docs/superpowers/spec Abschnitt 4) und an das, was
|
# 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
|
# server.py tatsaechlich liest. Seit Fixrunde 2 (N2) traegt "ninlight_version" PRO
|
||||||
# Ebene, nicht pro Eintrag - ein reines json.load haette das nicht gemerkt, weil es
|
# EINTRAG die Version - hier stimmen beide Eintraege ueberein, deshalb steht das
|
||||||
# nur die Feldnamen INNERHALB der Eintraege prueft. Die naechste Pruefung (Szenario
|
# manifestweite Feld zusaetzlich als Bequemlichkeit oben. Ein reines json.load haette
|
||||||
# 20) schickt dieselbe Datei zusaetzlich durch den echten Server-Leser.
|
# 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 "
|
CHECK=$(python3 -c "
|
||||||
import json
|
import json
|
||||||
with open('$MANIFEST') as fh:
|
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(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)
|
and all(e.get('ninlight_version') == '1.0.0' for e in entries)
|
||||||
)
|
)
|
||||||
print('OK' if ok else 'FAIL: ' + json.dumps(data))
|
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 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
|
else
|
||||||
fail "manifest.json-Inhalt unerwartet: $CHECK"
|
fail "manifest.json-Inhalt unerwartet: $CHECK"
|
||||||
fi
|
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-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
|
# 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'
|
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": [
|
"images": [
|
||||||
{"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x"},
|
{"target": "bar", "variant": "prod", "file": "bar_prod.img", "bytes": 20, "sha256": "x",
|
||||||
{"target": "bar", "variant": "dev", "file": "bar_dev.img", "bytes": 19, "sha256": "y"}
|
"ninlight_version": "0.9.0"},
|
||||||
|
{"target": "bar", "variant": "dev", "file": "bar_dev.img", "bytes": 19, "sha256": "y",
|
||||||
|
"ninlight_version": "0.9.0"}
|
||||||
]}
|
]}
|
||||||
JSON
|
JSON
|
||||||
: >"$LOG"
|
: >"$LOG"
|
||||||
@@ -639,15 +645,33 @@ MANIFEST_CHECK=$(python3 -c "
|
|||||||
import json
|
import json
|
||||||
with open('$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json') as fh:
|
with open('$SANDBOX_STATE/opt/ninlight-station/images/migrate/manifest.json') as fh:
|
||||||
data = json.load(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')}
|
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)
|
" 2>&1)
|
||||||
set -e
|
set -e
|
||||||
if [ "$MANIFEST_CHECK" = "OK" ]; then
|
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
|
else
|
||||||
fail "manifest.json verliert Eintraege beim gezielten Deploy: $MANIFEST_CHECK"
|
fail "manifest.json-Inhalt nach dem gezielten Deploy unerwartet: $MANIFEST_CHECK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
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
|
# json.load allein (wie in Szenario 11) haette die falsche Verschachtelung von
|
||||||
# ninlight_version nicht gemerkt, weil es nur die Feldnamen innerhalb der Eintraege
|
# ninlight_version nicht gemerkt, weil es nur die Feldnamen innerhalb der Eintraege
|
||||||
# prueft. Hier laeuft dieselbe Datei stattdessen durch server.py's eigene
|
# prueft. Hier laeuft dieselbe Datei stattdessen durch server.py's eigene
|
||||||
# Station.manifest_version() - genau die Funktion, die im echten Betrieb entscheidet,
|
# Station.image_version(target, variant) - genau die Funktion, die im echten Betrieb
|
||||||
# welche Version protokolliert und angezeigt wird.
|
# 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"
|
SANDBOX="$TMPROOT/s20"; STUBBIN="$TMPROOT/bin20"; LOG="$TMPROOT/s20.log"; SANDBOX_STATE="$TMPROOT/s20-state"
|
||||||
setup_sandbox "$SANDBOX"
|
setup_sandbox "$SANDBOX"
|
||||||
setup_stubbin "$STUBBIN" "$LOG"
|
setup_stubbin "$STUBBIN" "$LOG"
|
||||||
@@ -865,16 +891,63 @@ st = module.Station(
|
|||||||
mode='migration',
|
mode='migration',
|
||||||
control_path=Path('$TMPROOT/s20-control.json'),
|
control_path=Path('$TMPROOT/s20-control.json'),
|
||||||
)
|
)
|
||||||
print(st.manifest_version())
|
print(st.image_version('foo', 'dev'))
|
||||||
" 2>&1)
|
" 2>&1)
|
||||||
RC2=$?
|
RC2=$?
|
||||||
set -e
|
set -e
|
||||||
if [ "$RC2" -eq 0 ] && [ "$VERSION" = "1.0.0" ]; then
|
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
|
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
|
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
|
echo
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
echo "Alle Tests bestanden."
|
echo "Alle Tests bestanden."
|
||||||
|
|||||||
@@ -686,6 +686,9 @@ def test_range_resume_on_migration_image(migration_server):
|
|||||||
|
|
||||||
|
|
||||||
def test_version_comes_from_manifest(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
|
srv, imgdir = migration_server
|
||||||
(imgdir / "migrate").mkdir(exist_ok=True)
|
(imgdir / "migrate").mkdir(exist_ok=True)
|
||||||
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
|
(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"
|
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):
|
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
||||||
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
||||||
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
||||||
|
|||||||
Reference in New Issue
Block a user