feat(station): Migrationsroute mit Horch- und Freigabephase

server.py bedient jetzt zwei Modi ueber denselben Dienst: --mode/--mode-file/--control
legen fest, ob ninlight_<target>_ota.img (Rettung) oder qcx_firmware_<pid>_..._ota_light.img
(Migration alter Klyqa-Lampen, via products.parse_request) ausgeliefert wird. Der Modus
kommt wahlweise aus einer Datei statt einem Startparameter, damit Task 4 ihn spaeter ohne
Dienst-Neustart umschalten kann. Die Steuerdatei control.json (Phase auto/listen/armed)
wird bei jeder Anfrage frisch gelesen und atomar mit 0644 geschrieben - eine fehlende oder
kaputte Steuerdatei blockiert eine Migration nie, sondern faellt auf "auto" zurueck. Nach
einer erfolgreichen Auslieferung in Phase "armed" faellt die Freigabe erst nach dem
vollstaendigen Senden auf "listen" zurueck, nie davor, damit ein abgebrochener Transfer die
Freigabe fuer den naechsten Bootversuch stehen laesst. status.json bekommt die
Migrationsfelder aus Spec 5.2 (mode, phase, armed_target, last_seen).

deploy.sh liefert products.py mit aus, sonst startet der Dienst nach dem naechsten Deploy
mit ModuleNotFoundError; test_deploy.sh zieht die Sandbox-Dummy-Datei entsprechend nach.

Neue Tests in test_server.py verwenden eigene server/migration_server-Fixtures samt
Testklient (get/status/set_control/read_control/delete_control/write_raw_control), ohne die
bestehende station-Fixture und ihre Tests anzufassen.
This commit is contained in:
Michael Sauer
2026-09-13 18:06:25 +02:00
parent e897164960
commit b41ed51018
4 changed files with 437 additions and 16 deletions
+3 -2
View File
@@ -76,7 +76,7 @@ echo "== uebertragen =="
# ausgelieferte Verzeichnis. # ausgelieferte Verzeichnis.
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen. # -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 -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 # Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen. # ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE' "$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
@@ -113,9 +113,10 @@ else
sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/" sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/"
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 0755 /tmp/ninlight-station "$BIN_PATH" sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_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 sudo systemctl daemon-reload
if systemctl is-active --quiet ninlight-station; then if systemctl is-active --quiet ninlight-station; then
if sudo systemctl restart ninlight-station; then if sudo systemctl restart ninlight-station; then
+207 -14
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/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 Serves firmware images to lamps that look for a rescue station while booting (mode
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a "recovery": ninlight_<target>_ota.img) or to old Klyqa lamps looking for their original
stray file in the image directory can never leave the machine. update server (mode "migration": qcx_firmware_<product-id>_<version>_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. 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 http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path 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") 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$") PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
TRAILER_SIZE = 512 TRAILER_SIZE = 512
@@ -54,13 +63,21 @@ def trailer_version(path: Path) -> str:
class Station: 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.image_dir = image_dir
self.state_path = state_path self.state_path = state_path
self.stall_after = stall_after 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.since = now_iso()
self.served = 0 self.served = 0
self.recent: list[dict] = [] 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, # 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 # 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 # 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: def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img" 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 <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)."""
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: 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. """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] del self.recent[:-RECENT_MAX]
self.write_state() 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: def write_state(self) -> None:
with self.lock: 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: try:
self.state_path.parent.mkdir(parents=True, exist_ok=True) self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-") fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
@@ -172,6 +275,12 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: def do_GET(self) -> None:
started = time.monotonic() 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) m = PATH_RE.match(self.path)
if not m: if not m:
self.reject(404, "not found") self.reject(404, "not found")
@@ -187,6 +296,66 @@ class Handler(BaseHTTPRequestHandler):
self.log(404, 0, started) self.log(404, 0, started)
return 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 size = path.stat().st_size
start = 0 start = 0
rng = self.headers.get("Range", "") rng = self.headers.get("Range", "")
@@ -195,7 +364,6 @@ class Handler(BaseHTTPRequestHandler):
if first.isdigit() and int(first) < size: if first.isdigit() and int(first) < size:
start = int(first) start = int(first)
version = trailer_version(path)
self.send_response(206 if start else 200) self.send_response(206 if start else 200)
self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(size - start)) self.send_header("Content-Length", str(size - start))
@@ -240,19 +408,43 @@ class Handler(BaseHTTPRequestHandler):
sent += len(chunk) sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, TimeoutError): except (BrokenPipeError, ConnectionResetError, TimeoutError):
status = "aborted" 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.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: 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("--image-dir", required=True, type=Path)
p.add_argument("--bind", default="192.168.8.1") p.add_argument("--bind", default="192.168.8.1")
p.add_argument("--port", type=int, default=80) p.add_argument("--port", type=int, default=80)
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json")) 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, p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open") help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv) 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) print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
return 2 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 = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined] httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img")) 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) f"{', '.join(images) or 'none'}", flush=True)
if a.stall_after is not None: if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True) print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
+1
View File
@@ -32,6 +32,7 @@ setup_sandbox() {
chmod +x "$sandbox/tools/station/deploy.sh" chmod +x "$sandbox/tools/station/deploy.sh"
# Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen"). # Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen").
echo "# dummy" >"$sandbox/tools/station/server.py" 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"
echo "# dummy" >"$sandbox/tools/station/ninlight-station.service" echo "# dummy" >"$sandbox/tools/station/ninlight-station.service"
+226
View File
@@ -453,3 +453,229 @@ def test_concurrent_downloads_are_all_recorded(station):
assert entry["status"] == "ok" assert entry["status"] == "ok"
assert entry["target"] == "evk" assert entry["target"] == "evk"
assert entry["bytes"] == expected_len 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"