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
+1
View File
@@ -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"
+226
View File
@@ -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"