Fix onboard WebKit preview and archive board telemetry locally

This commit is contained in:
DCCONSTRUCTIONS
2026-09-08 01:37:52 +03:00
parent d8afb61229
commit f0802d2713
46 changed files with 2623 additions and 40 deletions
+168
View File
@@ -0,0 +1,168 @@
import importlib.util
import sqlite3
import time
from pathlib import Path
from uuid import uuid4
import pytest
from k1link.fleet.monitor import SCHEMA, MonitorReceiver, MonitorReplica
ROOT = Path(__file__).resolve().parents[1]
def module(name):
spec = importlib.util.spec_from_file_location(
name, ROOT / "apps/node-agent/monitor" / (name + ".py")
)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
def record(seq, at, boot, value=25):
return dict(seq=seq, at=at, boot_id=boot, uptime=seq, values={"cpu.usage": value}, events=[])
def envelope(source, rows, latest=None):
return dict(
schema=SCHEMA,
source_id=source,
storage="ready",
latest=latest or rows[-1],
definitions={
"cpu.usage": dict(label="CPU", group="CPU", resource="cpu", unit="%", reason=None)
},
batch=dict(schema=SCHEMA, source_id=source, samples=rows),
)
def test_replica_retries_restart_backfill_and_latest_are_separate(tmp_path):
source, boot = str(uuid4()), str(uuid4())
now = time.time()
rows = [record(i, now - 40 + i, boot) for i in range(1, 41)]
archive = MonitorReplica(tmp_path)
assert archive.ingest("board", envelope(source, rows[:32], rows[-1]))["after"] == 32
result = archive.query("board")
assert result["latest"]["seq"] == 40 and result["backlog"] == 8
assert archive.db.execute("SELECT count(*) FROM samples").fetchone()[0] == 32
archive.close()
archive = MonitorReplica(tmp_path)
# A lost reply cannot duplicate history; ACK comes from the durable commit.
assert archive.ingest("board", envelope(source, rows[:32], rows[-1]))["after"] == 32
assert archive.ingest("board", envelope(source, rows[32:]))["after"] == 40
assert archive.db.execute("SELECT count(*) FROM samples").fetchone()[0] == 40
assert archive.query("board")["backlog"] == 0
# Retention on the board can leave a real gap; no zero samples are invented.
assert archive.ingest("board", envelope(source, [record(51, now + 11, boot)]))["after"] == 51
archive.close()
def test_conflicting_or_nonfinite_batch_rolls_back_and_missing_values_are_null(tmp_path):
source, boot = str(uuid4()), str(uuid4())
now = time.time()
archive = MonitorReplica(tmp_path)
original = record(2, now - 20, boot)
archive.ingest("board", envelope(source, [original]))
bad = envelope(source, [record(1, now - 21, boot), {**original, "values": {"cpu.usage": 99}}])
with pytest.raises(ValueError):
archive.ingest("board", bad)
assert archive.db.execute("SELECT count(*) FROM samples").fetchone()[0] == 1
with pytest.raises(ValueError):
archive.ingest("board", envelope(source, [record(3, now, boot, float("nan"))]))
archive.ingest("board", envelope(source, [record(3, now, boot, None)]))
point = archive.query("board")["series"][-1]
assert point["max"] is None and point["count"] == 0
archive.close()
def test_receiver_failure_isolated_and_offline_history_reopens(tmp_path, monkeypatch):
source, boot = str(uuid4()), str(uuid4())
archive = MonitorReplica(tmp_path)
archive.ingest("board", envelope(source, [record(1, time.time(), boot)]))
archive.close()
receiver = MonitorReceiver(tmp_path)
try:
deadline = time.monotonic() + 2
while not receiver.query("board")["available"] and time.monotonic() < deadline:
time.sleep(0.01)
assert receiver.query("board")["available"]
monkeypatch.setattr(
receiver.archive,
"ingest",
lambda *_: (_ for _ in ()).throw(sqlite3.OperationalError("synthetic full disk")),
)
start = time.monotonic()
assert receiver.submit("board", envelope(source, [record(2, time.time(), boot)])) is None
assert time.monotonic() - start < 0.1
time.sleep(0.05)
assert receiver.acks.get("board") is None
finally:
receiver.close()
def test_linux_rates_distinguish_absent_initial_reset_and_gap(tmp_path):
now = [100]
def write(path, text):
target = tmp_path / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text)
def counters(cpu, idle, rx):
write("proc/stat", f"cpu {cpu} 0 0 {idle} 0 0 0 0\ncpu0 {cpu} 0 0 {idle} 0 0 0 0\n")
write("sys/class/net/eth0/statistics/rx_bytes", str(rx))
write("sys/class/net/eth0/speed", "-1")
write("sys/bus/usb/devices/1-2/idVendor", "0000")
write("sys/bus/usb/devices/1-2/speed", "480")
write(
"sys/fs/cgroup/system.slice/mission-core-node.service/memory.events", "oom 3\noom_kill 2\n"
)
metrics = module("linux_metrics").LinuxMetrics(tmp_path, lambda: now[0])
counters(100, 100, 1000)
first = metrics.sample()
assert first["cpu.usage"] is None and first["net.eth0.rx_bytes"] is None
now[0] += 1
counters(150, 150, 1512)
second = metrics.sample()
assert second["cpu.usage"] == 50 and second["net.eth0.rx_bytes"] == 512
assert second["net.eth0.speed"] is None
assert second["usb.1-2.speed"] == 480 and second["usb.1-2.traffic"] is None
assert second["service.mission-core-node.service.oom"] == 2
now[0] += 1
counters(5, 5, 100)
assert metrics.sample()["net.eth0.rx_bytes"] is None
now[0] += 11
counters(25, 25, 900)
assert metrics.sample()["cpu.usage"] is None
def test_journal_retains_classification_without_freeform_data():
classify = module("journal_events").classify
value = classify(
{
"__REALTIME_TIMESTAMP": "123000000",
"_TRANSPORT": "kernel",
"MESSAGE": "Out of memory: Killed process 23 secret argument",
}
)
assert value == dict(code="system-oom", kind="Kernel", at=123, locations=[])
assert classify({"MESSAGE": "private Wi-Fi password"}) is None
def test_long_range_aggregates_keep_peaks_counts_and_exclude_incomplete_minute(tmp_path):
source, boot = str(uuid4()), str(uuid4())
now = int(time.time()//60)*60
archive = MonitorReplica(tmp_path)
rows = [record(1, now-120, boot, 10), record(2, now-110, boot, 90),
record(3, now-60, boot, 30), record(4, now+1, boot, 100)]
value = envelope(source, rows)
archive.ingest('board', value)
archive.ingest('board', value) # Duplicate delivery cannot inflate aggregates.
result = archive.query('board', window=21600, end=now+10)
assert result['end'] == now
assert sum(point['count'] for point in result['series']) == 3
assert max(point['max'] for point in result['series']) == 90
assert min(point['min'] for point in result['series']) == 10
archive.close()
+2
View File
@@ -34,6 +34,7 @@ case "$1" in
esac
''')
(binary / "getent").write_text("#!/bin/sh\nexit 0\n")
(binary / "setup-monitor").write_text("#!/bin/sh\nprintf '%s\\n' monitor-setup >> \"$TEST_EVENTS\"\n")
(binary / "dpkg-query").write_text(
"#!/bin/sh\necho 'install ok " + ("installed" if configured else "unpacked") + "'\n"
)
@@ -45,6 +46,7 @@ esac
script = tmp_path / name
script.write_text((PACKAGING / name).read_text()
.replace("/run/", str(run) + "/")
.replace("/usr/lib/mission-core-node/setup-monitor", str(binary / "setup-monitor"))
.replace("/etc/os-release", str(release)))
subprocess.run(["/bin/sh", str(script), *args], env=env, check=True,
capture_output=True, text=True)
+98
View File
@@ -0,0 +1,98 @@
import asyncio
import json
from uuid import uuid4
import pytest
from k1link.viewer.node_local_media import acknowledge, admit_local, read_local, start_local
from k1link.viewer.node_media import NodeMediaPeers
def test_local_stream_fragments_ack_resume_and_releases_before_reconnect():
payload = b"RRF2" + bytes(range(256)) * 256
released = []
subscriptions = []
class Subscription:
pending = None
def __init__(self, after):
self.after = after
self.sent = False
def next_batch(self, **_):
if self.sent:
return b""
self.sent = True
self.pending = (self.after + 1, payload, None)
return self.pending
def snapshot(self, _):
return dict(sequence=self.after + 1, age_ms=0, points=5000)
def acknowledge(self, seq):
if seq == self.after + 1:
self.pending = None
def release(self):
released.append(self.after)
class Hub:
def subscribe(self, view_id, after):
sub = Subscription(after)
subscriptions.append((view_id, sub))
return sub
class Camera:
def snapshot(self):
return {"recording": {"source_end_expected": True}}
async def run():
peers = NodeMediaPeers(Hub(), Camera())
view = str(uuid4())
for after in (0, 1):
peer = await start_local(peers, dict(view_id=view, after=after))
if after:
assert released == [0] # Lost response/reconnect retires the old lease.
collected = []
while sum(map(len, collected)) < len(payload):
response = await asyncio.wait_for(read_local(peers, peer, after), 1.2)
assert len(response) <= 196608
offset = 0
while offset < len(response):
kind = response[offset]
size = int.from_bytes(response[offset + 1 : offset + 5], "big")
data = response[offset + 5 : offset + 5 + size]
if kind == 0:
assert json.loads(data)["peer_id"] == peer
if kind == 2:
if data.startswith(b"MCF1"):
assert int.from_bytes(data[4:], "big") == len(payload)
else:
collected.append(data)
offset += size + 5
assert b"".join(collected) == payload
acknowledge(peers, peer, after + 1)
assert subscriptions[-1][1].pending is None
await peers.close_all()
assert not peers.items and released == [0, 1]
assert subscriptions[0][0] == subscriptions[1][0] == view
asyncio.run(run())
@pytest.mark.parametrize("after", [True, -1, 2**53, "1"])
def test_local_cursor_rejects_noncanonical_values(after):
with pytest.raises(ValueError):
admit_local(dict(view_id=str(uuid4()), after=after))
def test_local_carrier_does_not_exceed_peer_capacity():
async def run():
class Peers:
items = {"a": {}, "b": {}}
with pytest.raises(ValueError):
await start_local(Peers(), dict(view_id=str(uuid4()), after=0))
asyncio.run(run())