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.
868 lines
36 KiB
Python
868 lines
36 KiB
Python
import hashlib
|
|
import http.client
|
|
import json
|
|
import os
|
|
import select
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SERVER = Path(__file__).resolve().parents[1] / "server.py"
|
|
TRAILER_SIZE = 512
|
|
RECENT_MAX = 8
|
|
|
|
|
|
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
|
|
"""Builds a file with the same trailer layout the firmware verifies."""
|
|
trailer = bytearray(b"\xff" * TRAILER_SIZE)
|
|
trailer[0:4] = b"NINL"
|
|
trailer[4] = 1
|
|
trailer[5:21] = device.encode().ljust(16, b"\x00")
|
|
trailer[21:37] = version.encode().ljust(16, b"\x00")
|
|
trailer[37:69] = hashlib.sha256(payload).digest()
|
|
data = payload + bytes(trailer)
|
|
path.write_bytes(data)
|
|
return data
|
|
|
|
|
|
def free_port() -> int:
|
|
s = socket.socket()
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
s.close()
|
|
return port
|
|
|
|
|
|
class Station:
|
|
def __init__(self, tmp_path, *extra):
|
|
self.image_dir = tmp_path / "images"
|
|
self.image_dir.mkdir()
|
|
self.state = tmp_path / "status.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), *extra],
|
|
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 conn(self):
|
|
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
|
|
|
def read_state(self, predicate=None):
|
|
"""Reads status.json, optionally waiting for it to satisfy `predicate`.
|
|
|
|
The server only writes the entry for a request after that request's response has
|
|
actually finished (or failed) on the wire - by design, so it never claims success
|
|
before the last byte is confirmed sent (see server.py). A client that has just
|
|
finished reading its own response can therefore still see a status.json that is a
|
|
few milliseconds stale. Rather than have the server promise something before it is
|
|
true, we wait here for the state to catch up.
|
|
"""
|
|
last = None
|
|
for _ in range(100):
|
|
if self.state.exists():
|
|
try:
|
|
last = json.loads(self.state.read_text())
|
|
if predicate is None or predicate(last):
|
|
return last
|
|
except json.JSONDecodeError:
|
|
pass
|
|
time.sleep(0.05)
|
|
if last is None:
|
|
raise AssertionError("no state file written")
|
|
raise AssertionError(f"state never satisfied predicate, last seen: {last!r}")
|
|
|
|
def stop(self):
|
|
self.proc.terminate()
|
|
self.proc.wait(timeout=5)
|
|
|
|
def wait_for_log_line(self, contains, timeout=3.0):
|
|
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
|
|
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
|
|
check that a request actually produced a log line (F3: every request must,
|
|
including rejected ones and errors the base handler catches before routing).
|
|
|
|
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
|
|
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
|
|
read() can slurp everything currently sitting in the pipe (banner *and* the log
|
|
line we're waiting for) into its own buffer on the very first call, while only
|
|
handing back the first line. select() then reports the fd as not-readable on the
|
|
next iteration - the data isn't gone, it is just buffered somewhere select() can't
|
|
see - and a fixed-size test timeout would flake on exactly the line it's checking
|
|
for.
|
|
"""
|
|
fd = self.proc.stdout.fileno()
|
|
buf = getattr(self, "_log_buf", "")
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
while "\n" in buf:
|
|
line, buf = buf.split("\n", 1)
|
|
self._log_buf = buf
|
|
if contains in line:
|
|
return line + "\n"
|
|
remaining = deadline - time.time()
|
|
if remaining <= 0:
|
|
self._log_buf = buf
|
|
return None
|
|
r, _, _ = select.select([fd], [], [], remaining)
|
|
if not r:
|
|
self._log_buf = buf
|
|
return None
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
self._log_buf = buf
|
|
return None
|
|
buf += chunk.decode("utf-8", "replace")
|
|
|
|
|
|
@pytest.fixture
|
|
def station(tmp_path):
|
|
s = Station(tmp_path)
|
|
yield s
|
|
s.stop()
|
|
|
|
|
|
def test_serves_a_known_image(station):
|
|
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_evk_ota.img")
|
|
r = c.getresponse()
|
|
body = r.read()
|
|
assert r.status == 200
|
|
assert int(r.getheader("Content-Length")) == len(data)
|
|
assert body == data
|
|
|
|
|
|
def test_missing_image_is_404(station):
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
|
assert c.getresponse().status == 404
|
|
|
|
|
|
def test_unknown_target_is_404(station):
|
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
|
|
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
|
|
c = station.conn()
|
|
c.request("GET", path)
|
|
assert c.getresponse().status == 404, path
|
|
|
|
|
|
def test_path_traversal_is_refused(station):
|
|
secret = station.image_dir.parent / "secret.txt"
|
|
secret.write_text("nicht ausliefern")
|
|
c = station.conn()
|
|
c.request("GET", "/../secret.txt")
|
|
assert c.getresponse().status == 404
|
|
|
|
|
|
def test_other_methods_are_405(station):
|
|
c = station.conn()
|
|
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
|
assert c.getresponse().status == 405
|
|
|
|
|
|
def test_rejected_method_produces_a_log_line(station):
|
|
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
|
|
the station is the only diagnostic tool during a rescue, and the spec requires a line
|
|
per request, rejections included."""
|
|
c = station.conn()
|
|
c.request("POST", "/ninlight_evk_ota.img", body=b"")
|
|
assert c.getresponse().status == 405
|
|
line = station.wait_for_log_line(" 405 ")
|
|
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
|
|
assert "/ninlight_evk_ota.img" in line
|
|
|
|
|
|
def test_log_error_is_not_silenced_by_log_message(capsys):
|
|
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
|
|
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
|
|
server.py silences log_message() in favour of its own per-request log() calls - which
|
|
used to silence log_error() right along with it, since it wasn't overridden separately.
|
|
This exercises Handler.log_error() directly rather than over a raw socket: this
|
|
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
|
|
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
|
|
genuine malformed-request-line test unreliable here."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
class _Stub:
|
|
client_address = ("203.0.113.5", 54321)
|
|
|
|
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
|
|
out = capsys.readouterr().out
|
|
assert "ERROR" in out
|
|
assert "203.0.113.5" in out
|
|
assert "code 400, message Bad request version" in out
|
|
|
|
|
|
def test_range_request_returns_206_and_the_right_slice(station):
|
|
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
|
|
r = c.getresponse()
|
|
body = r.read()
|
|
assert r.status == 206
|
|
assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}"
|
|
assert body == data[1000:]
|
|
|
|
|
|
def test_state_file_records_a_delivery(station):
|
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_evk_ota.img")
|
|
c.getresponse().read()
|
|
state = station.read_state(lambda s: s["served"] >= 1)
|
|
assert state["served"] == 1
|
|
entry = state["recent"][-1]
|
|
assert entry["target"] == "evk"
|
|
assert entry["version"] == "0.3.4"
|
|
assert entry["status"] == "ok"
|
|
assert entry["ip"] == "127.0.0.1"
|
|
assert entry["bytes"] == 1000 + TRAILER_SIZE
|
|
|
|
|
|
def test_state_file_records_a_missing_image(station):
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_g95_cw_ota.img")
|
|
c.getresponse().read()
|
|
state = station.read_state(lambda s: len(s["recent"]) >= 1)
|
|
assert state["served"] == 0
|
|
assert state["recent"][-1]["status"] == "notfound"
|
|
assert state["recent"][-1]["target"] == "g95_cw"
|
|
|
|
|
|
def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
|
|
"""status.json is written by a root service but read by a normal-user CLI.
|
|
|
|
tempfile.mkstemp() creates its file 0600 and os.replace() preserves whatever mode
|
|
the temp file had, so the server must explicitly widen the mode before the rename -
|
|
and must do so with chmod() rather than relying on the mode passed to mkstemp/open,
|
|
because that one is still narrowed by the process umask. A restrictive umask (as
|
|
root services on the Pi may well run under) must not bring the 0600 back.
|
|
"""
|
|
old_umask = os.umask(0o077)
|
|
try:
|
|
s = Station(tmp_path)
|
|
finally:
|
|
os.umask(old_umask)
|
|
try:
|
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
|
|
|
|
def world_readable() -> bool:
|
|
mode = stat.S_IMODE(s.state.stat().st_mode)
|
|
return bool(mode & stat.S_IROTH)
|
|
|
|
# Written once already at startup (Station.__init__ -> write_state()).
|
|
s.read_state(lambda _: world_readable())
|
|
assert world_readable(), "status.json is not world-readable after the initial write"
|
|
|
|
# A second write (a real delivery) must not lose the mode either - e.g. if the
|
|
# widening were only applied to the file's very first creation instead of to
|
|
# every temp file, mode 0600 would silently come back on every later write.
|
|
c = s.conn()
|
|
c.request("GET", "/ninlight_evk_ota.img")
|
|
c.getresponse().read()
|
|
s.read_state(lambda st: st["served"] >= 1)
|
|
assert world_readable(), "status.json lost its mode on a later overwrite"
|
|
finally:
|
|
s.stop()
|
|
|
|
|
|
def test_recent_is_capped_at_eight(station):
|
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
|
|
for _ in range(12):
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_evk_ota.img")
|
|
c.getresponse().read()
|
|
state = station.read_state(lambda s: s["served"] >= 12)
|
|
assert state["served"] == 12
|
|
assert len(state["recent"]) == RECENT_MAX
|
|
|
|
|
|
def test_aborted_transfer_is_recorded(tmp_path):
|
|
s = Station(tmp_path, "--stall-after", "256")
|
|
try:
|
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
|
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
sock.recv(4096)
|
|
time.sleep(0.3)
|
|
sock.close()
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
try:
|
|
entry = json.loads(s.state.read_text())["recent"][-1]
|
|
if entry["status"] == "aborted":
|
|
assert entry["bytes"] < 100000
|
|
return
|
|
except (OSError, json.JSONDecodeError, IndexError, KeyError):
|
|
pass
|
|
time.sleep(0.1)
|
|
raise AssertionError("abort was not recorded")
|
|
finally:
|
|
s.stop()
|
|
|
|
|
|
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
|
|
s = Station(tmp_path, "--stall-after", "512")
|
|
try:
|
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
got = b""
|
|
sock.settimeout(2)
|
|
try:
|
|
while len(got) < 200000:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
got += chunk
|
|
except socket.timeout:
|
|
pass
|
|
# far less than the full image
|
|
assert len(got) < 20000
|
|
# and the connection is genuinely still open: a server that (wrongly) closed it
|
|
# after the stall boundary would make this recv() return b"" (EOF) instead of
|
|
# timing out. Just counting received bytes above would not catch that - a server
|
|
# closing the connection right after --stall-after also sends far less than
|
|
# 20000 bytes.
|
|
sock.settimeout(1)
|
|
with pytest.raises(socket.timeout):
|
|
sock.recv(1)
|
|
sock.close()
|
|
finally:
|
|
s.stop()
|
|
|
|
|
|
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
|
|
"""--stall-after must stop a client for good, not just for one request.
|
|
|
|
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
|
|
on the connection, and opens a new one with a Range header picking up right where it
|
|
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
|
|
exercises for a single request. A per-request byte counter would hand that resumed
|
|
request a brand-new --stall-after allowance, so the "stall" would only ever delay
|
|
delivery instead of actually stopping it - which is what was observed on the EVK: the
|
|
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
|
|
away with the complete image. This test reproduces that reconnect and checks that the
|
|
second, resumed request gets no further bytes at all.
|
|
"""
|
|
s = Station(tmp_path, "--stall-after", "1000")
|
|
try:
|
|
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
|
|
|
|
# First request: burns through the whole --stall-after budget, then the server
|
|
# holds the connection open and sends nothing further.
|
|
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
|
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
got = b""
|
|
sock.settimeout(2)
|
|
try:
|
|
while len(got) < 200000:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
got += chunk
|
|
except socket.timeout:
|
|
pass
|
|
assert len(got) < 20000 # far less than the full image, same bound as above
|
|
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
|
|
|
|
# Second request, resuming with Range exactly like a real lamp would. This
|
|
# client's cumulative budget is already spent, so it must get zero more bytes of
|
|
# body - not a fresh --stall-after allowance just because it's a new connection.
|
|
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
|
|
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
|
|
sock2.settimeout(2)
|
|
header = b""
|
|
while b"\r\n\r\n" not in header:
|
|
header += sock2.recv(4096)
|
|
assert b" 206 " in header.split(b"\r\n", 1)[0]
|
|
body_after_header = header.split(b"\r\n\r\n", 1)[1]
|
|
assert body_after_header == b"", "resumed request must not receive any body bytes"
|
|
try:
|
|
more = sock2.recv(1)
|
|
except socket.timeout:
|
|
more = None
|
|
assert more in (None, b""), "server sent data after the client's stall budget was spent"
|
|
sock2.close()
|
|
finally:
|
|
s.stop()
|
|
|
|
|
|
def test_concurrent_downloads_are_all_recorded(station):
|
|
"""Several lamps hitting the station at once must not lose or corrupt status.json.
|
|
|
|
Station.served is a shared counter and Station.recent a shared list, mutated from one
|
|
handler thread per connection under ThreadingHTTPServer. Without a lock around the
|
|
read-modify-write of `served` and the recent-list mutation + serialize-to-disk step,
|
|
concurrent requests can lose increments (two threads read the same old value) or have
|
|
one thread's status.json write clobber another's.
|
|
"""
|
|
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
|
|
expected_len = 5000 + TRAILER_SIZE
|
|
n_workers = 20
|
|
errors = []
|
|
results_lock = threading.Lock()
|
|
|
|
def worker():
|
|
try:
|
|
c = station.conn()
|
|
c.request("GET", "/ninlight_evk_ota.img")
|
|
r = c.getresponse()
|
|
body = r.read()
|
|
if r.status != 200 or len(body) != expected_len:
|
|
with results_lock:
|
|
errors.append((r.status, len(body)))
|
|
except Exception as e: # pragma: no cover - surfaced via the errors list below
|
|
with results_lock:
|
|
errors.append(e)
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert not errors, f"some downloads failed or returned the wrong body: {errors}"
|
|
|
|
state = station.read_state(lambda s: s["served"] >= n_workers)
|
|
assert state["served"] == n_workers
|
|
assert len(state["recent"]) == min(n_workers, RECENT_MAX)
|
|
for entry in state["recent"]:
|
|
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.
|
|
|
|
status()/read_control() folgen demselben Muster wie Station.read_state() oben
|
|
(Zeilen 68-90): sie pollen bis zu einigen Sekunden auf ein optionales Praedikat, statt
|
|
sich auf eine feste Pause nach get() zu verlassen. Grund ist derselbe wie dort -
|
|
server.py schreibt status.json/control.json fuer eine Anfrage erst, nachdem der
|
|
Antwortkoerper vollstaendig gesendet ist, aber http.client's read() kann zurueckkehren,
|
|
sobald Content-Length Bytes angekommen sind, was diesem Schreiben knapp zuvorkommen
|
|
kann. Jeder Aufrufer kennt sein eigenes Praedikat (das ohnehin schon als Assertion
|
|
dasteht), daher kein pauschales Warten hier."""
|
|
|
|
def __init__(self, tmp_path, mode):
|
|
self.image_dir = tmp_path / "images"
|
|
self.image_dir.mkdir()
|
|
self.state_path = tmp_path / "status.json"
|
|
# Eigenes Unterverzeichnis fuer die Steuerdatei, getrennt von status.json: ein Test
|
|
# fuer I3 (Fixrunde 1) macht genau dieses Verzeichnis schreibgeschuetzt, um einen
|
|
# fehlschlagenden write_control() zu erzwingen, und darf dabei nicht auch das
|
|
# Schreiben von status.json lahmlegen, ueber das derselbe Test den Fehler beobachtet.
|
|
control_dir = tmp_path / "control"
|
|
control_dir.mkdir()
|
|
self.control_path = control_dir / "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()
|
|
return _Response(r.status, dict(r.getheaders()), body)
|
|
finally:
|
|
conn.close()
|
|
|
|
def _poll_json(self, path, predicate=None, timeout=5.0):
|
|
"""Wie Station.read_state() oben: liest `path` wiederholt (bis zu `timeout`
|
|
Sekunden), bis er sich parsen laesst und - falls angegeben - `predicate` erfuellt.
|
|
Ohne predicate reicht ein einziges erfolgreiches Parsen, genau wie dort."""
|
|
last = None
|
|
last_err = None
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
last = json.loads(path.read_text())
|
|
if predicate is None or predicate(last):
|
|
return last
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
last_err = e
|
|
time.sleep(0.02)
|
|
if last is None:
|
|
raise AssertionError(f"could not read {path}: {last_err}")
|
|
raise AssertionError(f"{path} never satisfied predicate, last seen: {last!r}")
|
|
|
|
def status(self, predicate=None):
|
|
return self._poll_json(self.state_path, predicate)
|
|
|
|
def read_control(self, predicate=None):
|
|
return self._poll_json(self.control_path, predicate)
|
|
|
|
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
|
|
st = srv.status(lambda s: s.get("last_seen") is not None)
|
|
assert st["last_seen"]["target"] == "g95_cw"
|
|
assert st["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(lambda s: len(s["recent"]) >= 1)
|
|
assert st["last_seen"]["target"] == "g95_cw" # erkannt
|
|
assert st["served"] == 0 # aber nichts ausgeliefert
|
|
assert st["recent"][-1]["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(lambda c: c.get("phase") == "listen")["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
|
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
|
assert st["recent"][-1]["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
|
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
|
assert st["recent"][-1]["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):
|
|
"""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
|
|
(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")
|
|
st = srv.status(lambda s: len(s["recent"]) >= 1)
|
|
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):
|
|
"""Dieselbe 0600-Falle wie bei status.json (siehe
|
|
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
|
|
control.json: das CLI aus Task 4 muss die Datei lesen UND schreiben koennen."""
|
|
old_umask = os.umask(0o077)
|
|
try:
|
|
srv = MigrationTestServer(tmp_path, "migration")
|
|
finally:
|
|
os.umask(old_umask)
|
|
try:
|
|
(srv.image_dir / "migrate").mkdir(exist_ok=True)
|
|
(srv.image_dir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
|
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
|
|
|
def world_readable():
|
|
mode = stat.S_IMODE(srv.control_path.stat().st_mode)
|
|
return bool(mode & stat.S_IROTH)
|
|
|
|
# set_control() oben schreibt direkt aus dem Test, nicht ueber den Server - das
|
|
# will hier nicht geprueft werden. Erst die Anfrage unten bringt den Server selbst
|
|
# dazu, control.json zu schreiben (armed -> listen), also denselben Codepfad
|
|
# (Station.write_control()) wie write_state() fuer status.json.
|
|
assert srv.get(
|
|
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
|
|
srv.read_control(lambda _: world_readable())
|
|
assert world_readable(), "control.json ist nach dem Schreiben durch den Server " \
|
|
"nicht welt-lesbar"
|
|
finally:
|
|
srv.stop()
|
|
|
|
|
|
def test_armed_serves_exactly_once_under_concurrency(migration_server):
|
|
"""C1 (Fixrunde 1): read_control()/Phasenpruefung/Auslieferung/write_control("listen")
|
|
liefen ohne gemeinsame Sperre ab - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst-
|
|
und Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und
|
|
beide die volle Auslieferung bekommen. station.claim_delivery() muss das Lesen und das
|
|
Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
|
|
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
|
|
srv, imgdir = migration_server
|
|
(imgdir / "migrate").mkdir(exist_ok=True)
|
|
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
|
|
srv.set_control(phase="armed", armed_target="g95_cw")
|
|
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
|
|
|
|
n_workers = 20
|
|
results = []
|
|
results_lock = threading.Lock()
|
|
|
|
def worker():
|
|
r = srv.get(p)
|
|
with results_lock:
|
|
results.append(r.status)
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert len(results) == n_workers, "nicht alle Worker sind fertig geworden"
|
|
assert results.count(200) == 1, f"erwartet genau eine 200, bekommen: {results}"
|
|
assert results.count(404) == n_workers - 1
|
|
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
|
|
|
|
|
|
def test_control_write_failure_blocks_further_armed_deliveries(migration_server):
|
|
"""I3 (Fixrunde 1): schlaegt das Schreiben von control.json fehl (z.B. volles oder
|
|
schreibgeschuetztes /run), darf die Freigabe nicht dauerhaft auf "armed" stehen bleiben
|
|
- sonst wuerde jede weitere Lampe des Ziels ebenfalls bedient, genau die Luecke aus C1,
|
|
nur diesmal durch einen Schreibfehler statt durch Nebenlaeufigkeit ausgeloest. Die eine
|
|
Anfrage, die den Fehler ausloest, bekommt noch ihre Auslieferung (wie eine normale
|
|
Freigabe); jede weitere schlaegt fehl, bis das Schreiben wieder klappt. Der Fehler muss
|
|
ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
|
|
mehr wirkt."""
|
|
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"
|
|
|
|
control_dir = srv.control_path.parent
|
|
old_mode = control_dir.stat().st_mode
|
|
os.chmod(control_dir, 0o500) # r-x fuer den Besitzer, kein Schreiben mehr moeglich
|
|
try:
|
|
r1 = srv.get(p)
|
|
assert r1.status == 200, "die eine vorgesehene Auslieferung muss noch rausgehen"
|
|
st = srv.status(lambda s: s.get("control_error"))
|
|
assert st["control_error"], "Schreibfehler muss in status.json sichtbar sein"
|
|
|
|
r2 = srv.get(p)
|
|
assert r2.status == 404, \
|
|
"ein Schreibfehler darf die Freigabe nicht dauerhaft offen lassen"
|
|
finally:
|
|
os.chmod(control_dir, old_mode)
|
|
|
|
|
|
def _load_server_module():
|
|
"""Laedt server.py wie test_log_error_is_not_silenced_by_log_message oben - als
|
|
eigenstaendiges Modulobjekt, ohne es in sys.modules einzutragen, damit mehrfache
|
|
Aufrufe (einer je resolve_mode-Test) sich nicht in die Quere kommen."""
|
|
import importlib.util
|
|
|
|
spec = importlib.util.spec_from_file_location("ninlight_station_server_rm", SERVER)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_resolve_mode_explicit_mode_wins_over_file(tmp_path):
|
|
"""I5 (Fixrunde 1): --mode schlaegt --mode-file."""
|
|
module = _load_server_module()
|
|
mode_file = tmp_path / "mode"
|
|
mode_file.write_text("migration")
|
|
assert module.resolve_mode("recovery", mode_file) == "recovery"
|
|
|
|
|
|
def test_resolve_mode_reads_the_mode_file(tmp_path):
|
|
module = _load_server_module()
|
|
mode_file = tmp_path / "mode"
|
|
mode_file.write_text("migration")
|
|
assert module.resolve_mode(None, mode_file) == "migration"
|
|
|
|
|
|
def test_resolve_mode_missing_file_means_recovery(tmp_path):
|
|
module = _load_server_module()
|
|
mode_file = tmp_path / "mode" # bewusst nicht angelegt
|
|
assert module.resolve_mode(None, mode_file) == "recovery"
|
|
|
|
|
|
def test_resolve_mode_garbage_file_means_recovery(tmp_path):
|
|
module = _load_server_module()
|
|
mode_file = tmp_path / "mode"
|
|
mode_file.write_text("garbage\n")
|
|
assert module.resolve_mode(None, mode_file) == "recovery"
|
|
|
|
|
|
def test_resolve_mode_default_without_mode_file_is_recovery():
|
|
module = _load_server_module()
|
|
assert module.resolve_mode(None, None) == "recovery"
|