Fix onboard WebKit preview and archive board telemetry locally
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user