End-to-end pipeline now produces a labeled envelope from a single command.
Drives the orchestrator through an 8-phase XMRig-shaped schedule and
renders a 3-panel envelope (CPU%, RSS, IO write rate) with phase bands
sourced from labels.jsonl. Real telemetry, simulated load — validates the
collection + labeling shape before a real VM is involved.
Components:
- tools/load_mimic.py phase-driven load generator. Reads phase
commands on stdin; CPU/IO behavior matches
the named phase (clean=idle, armed=light burst,
infecting=disk burst+CPU, infected_running=
CPU saturation+stratum-shaped writes,
dormant=quieter than clean).
- tools/run_envelope_demo.py spawns load_mimic, drives EpisodeRunner with
a default 85s schedule that includes the
classic infected_running → dormant → re-entry
pattern.
- tools/plot_envelope.py reads telemetry + labels from an episode dir,
writes envelope.png with colored phase bands.
orchestrator: EpisodeRunner now takes an optional phase_schedule and an
on_phase callback. Walks the schedule emitting one label per transition.
Backwards-compatible — existing single-phase tests still green.
Doc fix (user pushback): README + architecture + threat-model no longer
imply the Pi5 is the deployment target. Pi5's actual role here is the
WireGuard-side collector for episode tarballs. Deployment target is
generic ("constrained Linux device"). The "gateway observer" concept
remains a deployment pattern, decoupled from the Pi5's collector role.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
3.7 KiB
Python
130 lines
3.7 KiB
Python
"""Plot a single episode's envelope.
|
|
|
|
Reads ``telemetry-proc.jsonl`` and ``labels.jsonl`` from an episode directory
|
|
and renders a 3-panel PNG: CPU%, RSS, IO write rate, with phase bands
|
|
underneath. Saves to ``<episode_dir>/envelope.png``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.patches import Patch
|
|
|
|
|
|
PHASE_COLORS = {
|
|
"clean": "#9bd09b",
|
|
"armed": "#f0d27a",
|
|
"infecting": "#ec9b58",
|
|
"infected_running": "#d05757",
|
|
"dormant": "#6f8ad6",
|
|
"reverting": "#bbbbbb",
|
|
}
|
|
|
|
|
|
def _load_jsonl(path: Path) -> list[dict]:
|
|
return [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(prog="plot_envelope")
|
|
parser.add_argument("episode_dir", type=Path)
|
|
parser.add_argument("--out", type=Path, default=None)
|
|
args = parser.parse_args()
|
|
|
|
d: Path = args.episode_dir
|
|
if not d.exists():
|
|
print(f"no such directory: {d}", file=sys.stderr)
|
|
return 2
|
|
|
|
proc_rows = _load_jsonl(d / "telemetry-proc.jsonl")
|
|
labels = _load_jsonl(d / "labels.jsonl")
|
|
if not proc_rows:
|
|
print("no proc telemetry rows found", file=sys.stderr)
|
|
return 2
|
|
|
|
t = [r["t_mono_ns"] / 1e9 for r in proc_rows]
|
|
cpu_jiffies = [r["cpu_user_jiffies"] + r["cpu_sys_jiffies"] for r in proc_rows]
|
|
rss_mib = [r["rss_bytes"] / (1024 * 1024) for r in proc_rows]
|
|
io_w = [r["io_write_bytes"] or 0 for r in proc_rows]
|
|
|
|
clk_tck = os.sysconf("SC_CLK_TCK")
|
|
|
|
cpu_pct: list[float] = [0.0]
|
|
io_kb_s: list[float] = [0.0]
|
|
for i in range(1, len(proc_rows)):
|
|
dt = t[i] - t[i - 1]
|
|
if dt <= 0:
|
|
cpu_pct.append(0.0)
|
|
io_kb_s.append(0.0)
|
|
continue
|
|
d_jiffies = cpu_jiffies[i] - cpu_jiffies[i - 1]
|
|
cpu_pct.append(100.0 * (d_jiffies / clk_tck) / dt)
|
|
io_kb_s.append(((io_w[i] - io_w[i - 1]) / 1024.0) / dt)
|
|
|
|
end_t = t[-1] if t else 0.0
|
|
spans: list[tuple[float, float, str]] = []
|
|
for i, lbl in enumerate(labels):
|
|
start = lbl["t_mono_ns"] / 1e9
|
|
end = labels[i + 1]["t_mono_ns"] / 1e9 if i + 1 < len(labels) else end_t
|
|
spans.append((start, end, lbl["phase"]))
|
|
|
|
fig, axes = plt.subplots(3, 1, figsize=(13, 8), sharex=True)
|
|
|
|
axes[0].plot(t, cpu_pct, color="#222222", linewidth=1.0)
|
|
axes[0].set_ylabel("CPU %")
|
|
axes[0].set_ylim(-3, 110)
|
|
axes[0].grid(alpha=0.25)
|
|
|
|
axes[1].plot(t, rss_mib, color="#222222", linewidth=1.0)
|
|
axes[1].set_ylabel("RSS (MiB)")
|
|
axes[1].grid(alpha=0.25)
|
|
|
|
axes[2].plot(t, io_kb_s, color="#222222", linewidth=1.0)
|
|
axes[2].set_ylabel("IO write (KiB/s)")
|
|
axes[2].set_xlabel("time (s)")
|
|
axes[2].grid(alpha=0.25)
|
|
|
|
for ax in axes:
|
|
for start, end, phase in spans:
|
|
ax.axvspan(
|
|
start, end,
|
|
color=PHASE_COLORS.get(phase, "#cccccc"),
|
|
alpha=0.30,
|
|
linewidth=0,
|
|
)
|
|
|
|
legend_handles = [
|
|
Patch(facecolor=PHASE_COLORS[p], alpha=0.5, label=p)
|
|
for p in PHASE_COLORS
|
|
if any(s[2] == p for s in spans)
|
|
]
|
|
axes[0].legend(
|
|
handles=legend_handles,
|
|
loc="upper right",
|
|
ncols=len(legend_handles),
|
|
fontsize=9,
|
|
framealpha=0.85,
|
|
)
|
|
|
|
fig.suptitle(
|
|
f"Episode {d.name} — envelope ({len(proc_rows)} samples, {end_t:.1f}s)"
|
|
)
|
|
fig.tight_layout()
|
|
|
|
out = args.out or (d / "envelope.png")
|
|
fig.savefig(out, dpi=120)
|
|
print(f"wrote {out}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|