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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user