diff --git a/tools/station/deploy.sh b/tools/station/deploy.sh index b98e345..eb03501 100755 --- a/tools/station/deploy.sh +++ b/tools/station/deploy.sh @@ -76,7 +76,7 @@ echo "== uebertragen ==" # ausgelieferte Verzeichnis. # -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen. rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/" -rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" +rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/products.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/" # Pfade als Variablen mit Default: so kann ein Test die Installation gegen # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. "$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE' @@ -113,9 +113,10 @@ else sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/" fi 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 0755 /tmp/ninlight-station "$BIN_PATH" sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH" -rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service +rm -rf /tmp/ninlight-images /tmp/server.py /tmp/products.py /tmp/ninlight-station /tmp/ninlight-station.service sudo systemctl daemon-reload if systemctl is-active --quiet ninlight-station; then if sudo systemctl restart ninlight-station; then diff --git a/tools/station/server.py b/tools/station/server.py index e941fd3..3111748 100755 --- a/tools/station/server.py +++ b/tools/station/server.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""ninlight recovery station. +"""ninlight recovery/migration station. -Serves firmware images to lamps that look for a rescue station while booting. Deliberately -minimal: it hands out nothing but files whose name matches ninlight__ota.img, so a -stray file in the image directory can never leave the machine. +Serves firmware images to lamps that look for a rescue station while booting (mode +"recovery": ninlight__ota.img) or to old Klyqa lamps looking for their original +update server (mode "migration": qcx_firmware___ota_light.img). +Deliberately minimal: only files matching one of these two exact patterns ever leave the +machine, and only the pattern for the active mode is served. Standard library only - the Pi gets no extra packages. """ @@ -24,6 +26,13 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +# products.py lives next to this file. sys.path[0] already covers that when server.py is +# run the normal way (as a script or via `python3 server.py ...`), but a test that loads +# this module through importlib.util.spec_from_file_location (see test_server.py's +# test_log_error_is_not_silenced_by_log_message) does not get that for free. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import products + TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw") PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$") TRAILER_SIZE = 512 @@ -54,13 +63,21 @@ def trailer_version(path: Path) -> str: class Station: - def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None: + def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None, + mode: str = "recovery", control_path: Path | None = None) -> None: self.image_dir = image_dir self.state_path = state_path self.stall_after = stall_after + self.mode = mode + self.control_path = control_path or Path("/run/ninlight-station/control.json") self.since = now_iso() self.served = 0 self.recent: list[dict] = [] + # Zuletzt erkanntes Modell im Migrationsmodus, auch wenn (noch) nichts ausgeliefert + # 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" # Cumulative bytes already delivered to each client while --stall-after is active, # keyed by client IP. A lamp whose download stalls re-opens a fresh connection and # resumes with Range right where it left off, so a per-request counter would hand @@ -77,6 +94,64 @@ class Station: def image_path(self, target: str) -> Path: return self.image_dir / f"ninlight_{target}_ota.img" + 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).""" + 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 + 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" + self._manifest_mtime = mtime + return self._manifest_version + + def read_control(self): + """Phase und Freigabe. Fehlt die Datei oder ist sie unlesbar/kaputt, gilt "auto" - + eine fehlende Steuerdatei darf eine Migration nie blockieren, sondern hoechstens + bequemer machen als beabsichtigt. Bei jeder Anfrage frisch gelesen, damit + "ninlight-station arm" ohne Neustart des Dienstes wirkt.""" + try: + d = json.loads(self.control_path.read_text()) + except (OSError, ValueError): + return "auto", None + phase = d.get("phase") + if phase not in ("auto", "listen", "armed"): + return "auto", None + return phase, d.get("armed_target") + + def write_control(self, phase: str, armed_target: str | None) -> None: + with self.lock: + try: + self.control_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=self.control_path.parent, prefix=".control-") + try: + with os.fdopen(fd, "w") as f: + json.dump({"phase": phase, "armed_target": armed_target}, f) + # Dasselbe Muster wie write_state() unten, aus demselben Grund: der + # Dienst laeuft als root, mkstemp() legt 0600 an, und ohne das + # explizite chmod() koennte das CLI die Steuerdatei nicht lesen. + os.chmod(tmp, 0o644) + os.replace(tmp, self.control_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + except OSError as e: + print(f"warning: cannot write {self.control_path}: {e}", flush=True) + def reserve_stall_bytes(self, ip: str, n: int) -> int: """How many of the next `n` bytes this client may still receive under --stall-after. @@ -99,9 +174,37 @@ class Station: del self.recent[:-RECENT_MAX] self.write_state() + def record_migration(self, ip: str, target: str | None, variant: str | None, + product_id: str, lamp_version: str, product_name: str | None, + version: str, nbytes: int, status: str) -> None: + """Protokolliert eine Migrationsanfrage. Anders als record() oben wird hier auch + dann ein Eintrag angelegt (Status "listen"/"unknown"/"notfound"), wenn nichts + ausgeliefert wurde - das ist der ganze Zweck der Horchphase. last_seen wird nur bei + einer erkannten Produkt-ID gesetzt (target is not None); eine unbekannte Produkt-ID + hat kein Modell, das die Anzeige zeigen koennte.""" + with self.lock: + if status == "ok": + self.served += 1 + entry = {"ts": now_iso(), "ip": ip, "target": target, "variant": variant, + "product_id": product_id, "lamp_version": lamp_version, + "version": version, "bytes": nbytes, "status": status} + self.recent.append(entry) + del self.recent[:-RECENT_MAX] + if target is not None: + self.last_seen = {"product_id": product_id, "target": target, + "variant": variant, "product_name": product_name, + "lamp_version": lamp_version, "ip": ip, "ts": entry["ts"]} + self.write_state() + def write_state(self) -> None: with self.lock: - payload = {"since": self.since, "served": self.served, "recent": list(self.recent)} + payload = {"since": self.since, "mode": self.mode, "served": self.served, + "recent": list(self.recent)} + if self.mode == "migration": + phase, armed_target = self.read_control() + payload["phase"] = phase + payload["armed_target"] = armed_target + payload["last_seen"] = self.last_seen try: self.state_path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-") @@ -172,6 +275,12 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: started = time.monotonic() + if self.station.mode == "migration": + self._do_get_migration(started) + else: + self._do_get_recovery(started) + + def _do_get_recovery(self, started: float) -> None: m = PATH_RE.match(self.path) if not m: self.reject(404, "not found") @@ -187,6 +296,66 @@ class Handler(BaseHTTPRequestHandler): self.log(404, 0, started) return + version = trailer_version(path) + sent, status = self._serve_file(path, started) + # Record only now that the transfer has actually finished (or failed): "ok" must + # never be written before the last byte is confirmed sent, or status.json can + # claim success for a device that never received the end of the image. + self.station.record(self.client_address[0], target, version, sent, status) + + def _do_get_migration(self, started: float) -> None: + parsed = products.parse_request(self.path) + if parsed is None: + # Ein fremder Pfad, kein qcx_firmware_*-Muster - kein Protokolleintrag in + # status.json, das waere nur Rauschen (Brief Schritt 3.4). + self.reject(404, "not found") + self.log(404, 0, started) + return + pid, lamp_version, product = parsed + ip = self.client_address[0] + if product is None: + self.station.record_migration(ip, None, None, pid, lamp_version, None, "?", 0, + "unknown") + self.reject(404, "unknown product") + self.log(404, 0, started) + return + + target, variant, name = product.target, product.variant, product.name + version = self.station.manifest_version() + phase, armed_target = self.station.read_control() + + if phase == "listen" or (phase == "armed" and armed_target != target): + # last_seen wird trotzdem gesetzt - das ist der ganze Zweck der Horchphase: + # die Anzeige soll zeigen, welches Modell da ist, auch wenn nichts rausgeht. + self.station.record_migration(ip, target, variant, pid, lamp_version, name, + version, 0, "listen") + self.reject(404, "listening only") + self.log(404, 0, started) + return + + path = self.station.migrate_image_path(target, variant) + if not path.is_file(): + self.station.record_migration(ip, target, variant, pid, lamp_version, name, + version, 0, "notfound") + self.reject(404, "no image for this device") + self.log(404, 0, started) + return + + sent, status = self._serve_file(path, started) + if phase == "armed" and status == "ok": + # Erst nach dem vollstaendigen Senden zurueck auf "listen" - _serve_file() ist + # an dieser Stelle bereits fertig. Bricht der Transfer ab (status == "aborted"), + # bleibt die Freigabe stehen, damit die Lampe es beim naechsten Boot noch + # einmal versuchen kann. + self.station.write_control("listen", None) + self.station.record_migration(ip, target, variant, pid, lamp_version, name, version, + sent, status) + + def _serve_file(self, path: Path, started: float) -> tuple[int, str]: + """Sendet `path` mit Range-Unterstuetzung; liefert (gesendete Bytes, Status). + Gemeinsam fuer Rettungs- und Migrationsroute - Uebertragungsmechanik (Range, + --stall-after, Abbruchbehandlung) ist identisch, nur was mit dem Ergebnis + geschieht, unterscheidet sich.""" size = path.stat().st_size start = 0 rng = self.headers.get("Range", "") @@ -195,7 +364,6 @@ class Handler(BaseHTTPRequestHandler): if first.isdigit() and int(first) < size: start = int(first) - version = trailer_version(path) self.send_response(206 if start else 200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Length", str(size - start)) @@ -240,19 +408,43 @@ class Handler(BaseHTTPRequestHandler): sent += len(chunk) except (BrokenPipeError, ConnectionResetError, TimeoutError): status = "aborted" - # Record only now that the transfer has actually finished (or failed): "ok" must - # never be written before the last byte is confirmed sent, or status.json can - # claim success for a device that never received the end of the image. self.log(206 if start else 200, sent, started) - self.station.record(self.client_address[0], target, version, sent, status) + return sent, status + + +def resolve_mode(explicit_mode: str | None, mode_file: Path | None) -> str: + """Bestimmt den Betriebsmodus. Vorrang: ein ausdrueckliches --mode schlaegt + --mode-file, --mode-file schlaegt die Vorgabe "recovery". Eine fehlende oder + unlesbare/unerwartete Modusdatei bedeutet ebenfalls "recovery" - Task 4 baut die + systemd-Unit darauf auf, dass der Modus ohne Neustart-Argumentaenderung aus einer + Datei kommt (/run/ninlight-station/mode), und ein fehlerhafter Zustand dieser Datei + darf den Dienst nicht zum Absturz bringen oder unbeabsichtigt scharf schalten.""" + if explicit_mode is not None: + return explicit_mode + if mode_file is not None: + try: + text = mode_file.read_text().strip() + except OSError: + return "recovery" + if text in ("recovery", "migration"): + return text + return "recovery" + return "recovery" def main(argv: list[str] | None = None) -> int: - p = argparse.ArgumentParser(description="ninlight recovery station HTTP server") + p = argparse.ArgumentParser(description="ninlight recovery/migration station HTTP server") p.add_argument("--image-dir", required=True, type=Path) p.add_argument("--bind", default="192.168.8.1") p.add_argument("--port", type=int, default=80) p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json")) + p.add_argument("--mode", choices=("recovery", "migration"), default=None, + help="ausdruecklicher Modus - schlaegt --mode-file (Vorgabe: siehe dort)") + p.add_argument("--mode-file", type=Path, default=None, + help="Datei mit 'recovery' oder 'migration'; wird ohne --mode verwendet, " + "Vorgabe bei fehlender/kaputter Datei ist 'recovery'") + p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"), + help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus") p.add_argument("--stall-after", type=int, default=None, help="test aid: stop sending after N bytes but keep the connection open") a = p.parse_args(argv) @@ -261,12 +453,13 @@ def main(argv: list[str] | None = None) -> int: print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr) return 2 - station = Station(a.image_dir, a.state, a.stall_after) + mode = resolve_mode(a.mode, a.mode_file) + station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control) httpd = ThreadingHTTPServer((a.bind, a.port), Handler) httpd.daemon_threads = True httpd.station = station # type: ignore[attr-defined] images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) - print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): " + print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): " f"{', '.join(images) or 'none'}", flush=True) if a.stall_after is not None: print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) diff --git a/tools/station/tests/test_deploy.sh b/tools/station/tests/test_deploy.sh index 347b973..85e3d56 100755 --- a/tools/station/tests/test_deploy.sh +++ b/tools/station/tests/test_deploy.sh @@ -32,6 +32,7 @@ setup_sandbox() { chmod +x "$sandbox/tools/station/deploy.sh" # Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen"). echo "# dummy" >"$sandbox/tools/station/server.py" + echo "# dummy" >"$sandbox/tools/station/products.py" echo "# dummy" >"$sandbox/tools/station/ninlight-station" echo "# dummy" >"$sandbox/tools/station/ninlight-station.service" diff --git a/tools/station/tests/test_server.py b/tools/station/tests/test_server.py index dbda832..1d6008c 100644 --- a/tools/station/tests/test_server.py +++ b/tools/station/tests/test_server.py @@ -453,3 +453,229 @@ def test_concurrent_downloads_are_all_recorded(station): assert entry["status"] == "ok" assert entry["target"] == "evk" assert entry["bytes"] == expected_len + + +# --- Migrationsroute (Task 3) ------------------------------------------------------- +# +# Eigener Testklient statt der Station-Klasse oben: die migration_server/server-Fixtures +# brauchen get()/status()/set_control()/read_control()/delete_control()/write_raw_control(), +# waehrend die Station-Klasse und die "station"-Fixture oben unveraendert fuer die +# bestehenden Rettungsmodus-Tests weiterlaufen sollen. + + +class _Response: + def __init__(self, status, headers, body): + self.status = status + self.headers = headers + self.body = body + + +class MigrationTestServer: + """Startet server.py mit --mode/--control und bietet die vom Brief geforderten + Hilfsmethoden. server.py schreibt status.json/control.json fuer eine Anfrage im + selben Handler-Thread, unmittelbar nachdem der Antwortkoerper vollstaendig gesendet + wurde - aber http.client's read() kann zurueckkehren, sobald Content-Length Bytes + angekommen sind, was diesem serverseitigen Schreiben knapp zuvorkommen kann. get() + legt deshalb eine kurze Pause ein, bevor es zurueckkehrt (derselbe Grund, aus dem + Station.read_state() oben mit einem Prädikat auf status.json wartet - hier gibt es + kein generisches Prädikat, das jeder Aufrufer kennt, darum die feste kleine Pause statt + einer Wiederholschleife).""" + + def __init__(self, tmp_path, mode): + self.image_dir = tmp_path / "images" + self.image_dir.mkdir() + self.state_path = tmp_path / "status.json" + self.control_path = tmp_path / "control.json" + self.port = free_port() + self.proc = subprocess.Popen( + [sys.executable, str(SERVER), "--image-dir", str(self.image_dir), + "--bind", "127.0.0.1", "--port", str(self.port), + "--state", str(self.state_path), "--mode", mode, + "--control", str(self.control_path)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + deadline = time.time() + 10 + while time.time() < deadline: + try: + socket.create_connection(("127.0.0.1", self.port), 0.2).close() + return + except OSError: + if self.proc.poll() is not None: + raise RuntimeError("server died: " + (self.proc.stdout.read() or "")) + time.sleep(0.05) + raise RuntimeError("server did not come up") + + def get(self, path, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) + try: + conn.request("GET", path, headers=headers or {}) + r = conn.getresponse() + body = r.read() + resp = _Response(r.status, dict(r.getheaders()), body) + finally: + conn.close() + # See the class docstring: a short pause so the server's own status.json/ + # control.json write for this request has landed before we return. + time.sleep(0.05) + return resp + + def _read_json(self, path, timeout=2.0): + deadline = time.time() + timeout + last_err = None + while time.time() < deadline: + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + last_err = e + time.sleep(0.02) + raise AssertionError(f"could not read {path}: {last_err}") + + def status(self): + return self._read_json(self.state_path) + + def read_control(self): + return self._read_json(self.control_path) + + def set_control(self, phase, armed_target=None): + tmp = self.control_path.with_name(self.control_path.name + ".tmp") + tmp.write_text(json.dumps({"phase": phase, "armed_target": armed_target})) + os.chmod(tmp, 0o644) + os.replace(tmp, self.control_path) + + def delete_control(self): + try: + self.control_path.unlink() + except FileNotFoundError: + pass + + def write_raw_control(self, text): + self.control_path.write_text(text) + + def stop(self): + self.proc.terminate() + self.proc.wait(timeout=5) + + +@pytest.fixture +def server(tmp_path): + srv = MigrationTestServer(tmp_path, "recovery") + yield srv, srv.image_dir + srv.stop() + + +@pytest.fixture +def migration_server(tmp_path): + srv = MigrationTestServer(tmp_path, "migration") + yield srv, srv.image_dir + srv.stop() + + +def test_migration_auto_serves_known_product(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000) + r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img") + assert r.status == 200 + assert r.body == b"IMG" * 1000 + assert srv.status()["last_seen"]["target"] == "g95_cw" + assert srv.status()["last_seen"]["variant"] == "dev" + + +def test_migration_listen_records_but_serves_nothing(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + srv.set_control(phase="listen") + r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") + assert r.status == 404 + st = srv.status() + assert st["last_seen"]["target"] == "g95_cw" # erkannt + assert st["served"] == 0 # aber nichts ausgeliefert + assert st["recent"][0]["status"] == "listen" + + +def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + srv.set_control(phase="armed", armed_target="g95_cw") + p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" + assert srv.get(p).status == 200 + assert srv.read_control()["phase"] == "listen" + assert srv.get(p).status == 404 + + +def test_armed_for_other_target_does_not_serve(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + srv.set_control(phase="armed", armed_target="gu10_rgb") + r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") + assert r.status == 404 + assert srv.read_control()["phase"] == "armed" # Freigabe bleibt stehen + + +def test_unknown_product_id_is_404_and_logged(migration_server): + srv, _ = migration_server + r = srv.get("/qcx_firmware_-acme.bulb_V1_ota_light.img") + assert r.status == 404 + assert srv.status()["recent"][0]["status"] == "unknown" + + +def test_missing_migrate_image_is_404(migration_server): + srv, _ = migration_server + r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img") + assert r.status == 404 + assert srv.status()["recent"][0]["status"] == "notfound" + + +def test_recovery_path_is_404_in_migration_mode(migration_server): + srv, imgdir = migration_server + (imgdir / "ninlight_evk_ota.img").write_bytes(b"x" * 600) + assert srv.get("/ninlight_evk_ota.img").status == 404 + + +def test_migration_path_is_404_in_recovery_mode(server): + srv, _ = server + assert srv.get( + "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 404 + + +def test_missing_control_file_means_auto(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + srv.delete_control() + assert srv.get( + "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 + + +def test_corrupt_control_file_means_auto(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + srv.write_raw_control("{das ist kein json") + assert srv.get( + "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200 + + +def test_range_resume_on_migration_image(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40) + p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img" + r = srv.get(p, headers={"Range": "bytes=1000-"}) + assert r.status == 206 + assert r.headers["Content-Range"] == "bytes 1000-10239/10240" + assert r.body == (bytes(range(256)) * 40)[1000:] + + +def test_version_comes_from_manifest(migration_server): + srv, imgdir = migration_server + (imgdir / "migrate").mkdir(exist_ok=True) + (imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG") + (imgdir / "migrate" / "manifest.json").write_text(json.dumps({ + "ninlight_version": "0.3.9", + "images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]})) + srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img") + assert srv.status()["recent"][0]["version"] == "0.3.9"