Simulated Cluster
The simulated cluster runs the real Joulie components on emulated bare-metal nodes, on one machine with Docker. The agent discovers and caps emulated hardware through the same files and tools it uses on a real node, the controller manager reads node power from the simulator’s OpenTelemetry metrics in Prometheus, and the scheduler extender scores real pods. The scripts in scripts/simcluster drive it, and tests/simcluster checks it end to end.
Use it to see a change to the agent, the controller manager, the extender, the chart or the simulator work across components. A change to discovery or capping alone is checked faster by the hwemu tests in cmd/agent (go test ./cmd/agent/ -run Hwemu).
Architecture
host: Docker
container $SIMCLUSTER_NAME (--privileged): one k3s server
Node <name>-infra: the only real kubelet (joulie.io/infra=true, joulie.io/hwemu-host=true)
monitoring Prometheus with its OTLP receiver; the Collector in modes otel-collector and otel-bridge
joulie-sim the simulator in emulate mode: trees in /var/lib/joulie-hwemu/<node>/,
their sys and state served through FUSE at /var/lib/joulie-hwemu/fuse/<node>/
joulie-system the controller manager; the scheduler extender, on the host network or behind its Service
joulie-system joulie-agent-emu-<i>: one agent per eligible fake Node, mounting its tree
KWOK keeps the fake Nodes Ready
fake Nodes rendered from the cluster spec
- One k3s server in Docker. The container
$SIMCLUSTER_NAME(defaultjoulie-sim) runs the k3s image pinned inscripts/simcluster/versions.env. Its own kubelet is the only real Node,<name>-infra, and runs every pod that really runs. The k3s state and the hwemu trees live in the named volumes<name>-k3sand<name>-hwemu, so they outlive a restart of the container: the trees, the limits agents wrote and the energy ledgers that keep exported counters from going back. The trees need no tmpfs: 200 emulated nodes take about 0.1 s per tick on the volume, depending on the machine (Tick cost). - Fake Nodes.
clusterspec render-nodesturns the cluster spec into Node objects with the labels, taints, capacity and architecture of their hardware profiles, and KWOK v0.8.0 keeps them Ready. Its controller image is pinned by digest (KWOK_IMAGEandKWOK_DIGESTinversions.env) and imported like Prometheus, so the cluster never pulls it. Every fake Node carriessim.joulie.io/cluster=<spec>andsim.joulie.io/pool=<pool>. - Emulated hardware. The simulator runs in emulate mode with the same spec: one tree per fake Node under
/var/lib/joulie-hwemuon the infra Node, all stepped on one tick.SIMCLUSTER_HWEMUsets how the agents reach the files:fuse, the default, through the simulator’s FUSE views, where a refused write fails at once and counters move at read time;files, the trees themselves. Infusethe k3s container gets/dev/fuse,up.shmakes the hwemu root a shared mount in it, anddeploy.shaddsvalues/simcluster-simulator-fuse.yamlandvalues/simcluster-fuse.yaml. - Emulated agents. The agent binary is the one a release ships, unmodified. With
agent.mode: emulatedthe chart renders one Deploymentjoulie-agent-emu-<i>per entry ofagent.emulated.nodes, on the infra Node, mounting its Node’s tree where the agent reads/sys,/proc/cpuinfoand the GPU tools (Agent chart values). Which Nodes get one is decided as in a real install:simcluster agentsrenders the chart’s agent DaemonSet with the same values files as the install and keeps the Nodes it would reach. The node selector must match, everyNoScheduleandNoExecutetaint must be tolerated (counting the tolerations the DaemonSet controller adds), and the architecture must match the image. Every other Node is printed with its reason. - Scheduler.
SIMCLUSTER_EXTENDERsets how the k3s scheduler reaches the extender.hostnet, the default: the extender runs on the infra Node’s host network and the scheduler calls127.0.0.1(scripts/simcluster/manifests/scheduler-config.yaml, the same file the Dagger integration job uses).service: the extender runs off the host network, as a real install defaults to, and the scheduler calls its Service at a fixed ClusterIP (scheduler-config-service.yamlandvalues/simcluster-extender-service.yaml). - Telemetry. The simulator pushes OTLP straight into Prometheus, and the controller manager reads node power and the facility from there with its default settings, or with the settings of the chosen telemetry mode.
- Access. Every cluster HTTP call goes through the API server’s service proxy. The only published port is the API server’s, on
127.0.0.1, at a port Docker picks. The kubeconfig is$SIMCLUSTER_DIR/kubeconfig;~/.kubeis never read or written. - Images. One
go build(linux/amd64, CGO off) builds the agent, controller manager, extender, simulator andfakesmifrom the working tree. Each binary is wrapped in the distroless image its release uses, taggedjoulie.local/joulie-<component>:dev-<first 12 hex of the binary's sha256>(for the simulator image, which also carriesfakesmi, a hash of both binaries) and imported into the cluster’s containerd. The images build in parallel, but the imports take turns, one at a time per cluster and only after k3s has imported its air-gap bundle, and a failed import is tried again, three times in all, while the container runs. Pods pull withIfNotPresent, and the deploy checks each pod’s image ID againstimages.env.
Requirements
- Host. amd64 Linux with Docker allowed to run a
--privilegedcontainer: k3s runs its kubelet and containerd inside it. The images are built for linux/amd64 only. - FUSE.
/dev/fuseon the host forSIMCLUSTER_HWEMU=fuse, the default;up.shrefuses to start without it.SIMCLUSTER_HWEMU=filesneeds none. - Tools.
docker,kubectl,helm,jq,sha256sumand Go. The scripts build with the toolchaingo.modnames (they setGOTOOLCHAIN), so the images match what CI builds. CI installs the Helm release thatHELM_VERSIONinversions.envpins. - k3s 1.34 on a cgroup v1 host.
stat -fc %T /sys/fs/cgroupprintstmpfson cgroup v1. k3s 1.35 and later refuse cgroup v1 unless the kubelet runs withfail-cgroupv1=false;up.shadds that flag on such a host for such a k3s, but only the pinned 1.34 is tested there. fs.inotify.max_user_instances. Every pod of the cluster runs on the one infra kubelet, so all their containers and file watches share the host’s per-user inotify limit with the host’s own programs. 128 is a common default. The smoke spec fits in it, but a scale run with many emulated agents, or several clusters on one host, may exhaust it. A k3s that gets no instance exits as it starts; the scripts then stop at once with the container’s exit status and log, and name the limit. The scripts never change host settings: check it withsysctl fs.inotify.max_user_instancesand raise it by hand before a large run.- Pod capacity. Every emulated agent is a pod on the infra Node.
up.shsizes its kubelet for the agents plus 50, at least 110 pods: up to 250 pods with the default /24 per Node, up to 1000 with a /22 per Node and the cluster CIDR10.64.0.0/12. Beyond 1000 it fails with “split across k3s agents”. A running container created for fewer pods is never resized:up.shasks for a recreate. - Network on the first run. The k3s system images, KWOK, Prometheus, the Collector and the distroless base are pulled once; third-party images are pulled by the digest
versions.envpins. WithSIMCLUSTER_AIRGAP=1the k3s system images come from the release’s air-gap bundle, kept in$SIMCLUSTER_DIR/imagesfor later runs.
Quick start
make simcluster-smoke SIMCLUSTER_KEEP=1
D=~/.cache/joulie/simcluster/joulie-sim-smoke
jq '{status: ."test.suite.run.status", failures}' $D/runs/latest/summary.json
export KUBECONFIG=$D/kubeconfig
kubectl get nodes -L sim.joulie.io/pool
kubectl get nodetwins -o custom-columns=NODE:.spec.nodeName,SRC:.status.powerMeasurement.source,W:.status.powerMeasurement.measuredNodePowerW
make simcluster-down SIMCLUSTER_NAME=joulie-sim-smoke
make simcluster-smoke creates the cluster joulie-sim-smoke from tests/simcluster/specs/smoke.yaml, deploys with tests/simcluster/smoke-values.yaml, runs TestSimclusterE2E, collects the results and deletes the cluster unless SIMCLUSTER_KEEP=1. It builds the images and compiles the test while the cluster starts. The test times out after ten rate windows of the timing, at least 20 minutes, unless SIMCLUSTER_TEST_TIMEOUT sets another. The smoke spec has nine Nodes: an Intel Xeon with RAPL, an AMD EPYC with energy counters only, an AMD EPYC with HSMP, an H100 NVL node, an H100 SXM node with persistence mode off, an MI300X node, a virtual machine, a managed Intel node with a taint of its own, which the agent tolerates, and a managed arm64 node, which no agent reaches because the images are built for amd64 only. The smoke values put every node in performance with CPU and GPU caps at 80 %; the test lowers them to 50 % halfway.
To keep a cluster and work on it step by step:
make simcluster-up # k3s, KWOK, Prometheus, the fake Nodes
make simcluster-deploy # images, simulator, Joulie; waits until every component reports
make simcluster-status
Targets
Each target runs one script of scripts/simcluster and nothing else, so make -n shows what will run. The scripts also run directly, and each starts with its usage.
| Target | Script | What it does |
|---|---|---|
simcluster-up | up.sh | Validates the spec (simcluster validate). Creates the k3s container, or reuses an existing one, starting it when stopped, if it was created for at least as many pods and as large pod CIDRs, with the same scheduler configuration (SIMCLUSTER_EXTENDER) and k3s image; otherwise it says to recreate. Installs KWOK, Prometheus and, in modes otel-collector and otel-bridge, the Collector. Applies the spec’s Nodes and deletes the fake Nodes the spec no longer names. |
simcluster-images | images.sh | Builds and imports the Joulie images, and the pinned third-party ones, and writes images.env. An unchanged binary keeps its tag and is not imported again. Run directly, --wait-container builds first and then waits for the cluster’s container to run, so the build overlaps the cluster’s start. |
simcluster-deploy | deploy.sh | Images, CRDs, the simulator, the agent placement, Joulie. Waits for the rollouts (polling one list of the namespace’s workloads, for 5 minutes plus 5 seconds per workload; on a timeout it prints what did not roll out and the pods that are not ready), one NodeHardware per emulated agent, the series of every measured Node and a NodeTwin per managed Node, then checks every pod’s image. -f <values> adds a Joulie values file when run directly. |
simcluster-reload C=<component> | reload.sh | Rebuilds and rolls out agent, controller-manager, scheduler, simulator or all; see the dev loop. |
simcluster-status | status.sh | Prints every Node with its pool, profile and NodeTwin power. Exits 1 when a Node is NotReady, a pod without joulie.io/workload-class is bound to a fake Node, a pod runs another image than images.env names, an emulated agent’s Node has no NodeHardware, or a measured Node has no series. |
simcluster-tree [NODE=<node>] | tree.sh | Without a Node, lists the Nodes that have a tree; with one, prints every file of its tree with its first line: the energy counters, limits, frequencies and GPU state its agent sees. |
simcluster-logs C=<component> [NODE=<node>] | logs.sh | The last 200 lines of agent (one Node’s, or every agent’s), controller-manager, scheduler, simulator, prometheus, otelcol, kwok or k3s. Run directly, it passes further flags such as -f to kubectl logs. |
simcluster-smoke | smoke.sh | See Quick start. |
simcluster-down | down.sh | Removes the container with its anonymous volumes and the named volumes <name>-k3s and <name>-hwemu. Keeps images/, runs/ and bin/ of the state directory. |
Settings
The scripts read these variables from the environment; make passes on the ones that are set.
| Variable | Default | Effect |
|---|---|---|
SIMCLUSTER_NAME | joulie-sim; joulie-sim-smoke for the smoke run | Container name and prefix of its volumes. It must match ^joulie-sim(-[a-z0-9]+)*$: the scripts touch no container or volume named otherwise. |
SIMCLUSTER_DIR | ${XDG_CACHE_HOME:-~/.cache}/joulie/simcluster/<name> | State directory, outside the repository (below). |
SIMCLUSTER_SPEC | tests/simcluster/specs/smoke.yaml | The cluster spec. tests/simcluster/specs/medium.yaml has 50 Nodes, 48 of them reached by the agent, with GPU sharing, AMD GPUs with a pinned power cap, Nodes without NFD labels or GPU driver tools, and an unmanaged pool. tests/simcluster/specs/profiles.yaml has one Node of every builtin profile. tests/simcluster/specs/demo.yaml has 10 Nodes in five hardware families, the cluster of the kubectl joulie recording (scripts/kubectl-joulie-demo.sh). |
SIMCLUSTER_NODES | (empty) | Instead of a spec, this many Nodes of one profile, for scale runs. |
SIMCLUSTER_SCALE_PROFILE | intel-xeon-2s-rapl | The profile of SIMCLUSTER_NODES. |
SIMCLUSTER_TELEMETRY | otel | The telemetry mode. |
SIMCLUSTER_POWER_SCOPE | components | The node power scope Joulie reads: components (CPU packages and GPUs) or host (hw.host.*, in every otel mode but otel-bridge). |
SIMCLUSTER_TIMING | dev | The timing profile, a file scripts/simcluster/timing/<name>.env. |
SIMCLUSTER_HWEMU | fuse | How the emulated agents reach their Node’s files: fuse (the simulator’s FUSE views of sys and state, mounted with propagation HostToContainer) or files (the trees’ own files). deploy.sh records it in deploy.env, so a reload keeps it. |
SIMCLUSTER_EXTENDER | hostnet | How the k3s scheduler reaches the extender: hostnet (the extender on the infra Node’s host network, at 127.0.0.1) or service (the extender off the host network, through its Service). The container is created for one of them; up.sh asks for a recreate to switch. |
SIMCLUSTER_PROFILES_DIR | (empty) | Local hardware profiles, read by the host tools only: the scripts always pass it as their -profiles-dir, so SIM_PROFILES_DIR does not apply. The simulator in the cluster reads its builtin profiles, so up.sh refuses a spec that needs a local one. |
SIMCLUSTER_EXTRA_VALUES | (empty) | Joulie values files, space separated, installed after values/simcluster.yaml and the generated files. They also decide which Nodes get an emulated agent. |
SIMCLUSTER_SIM_EXTRA_VALUES | (empty) | Simulator values files, space separated, installed after values/simcluster-simulator.yaml and the generated file. deploy.sh records them in deploy.env, so a later reload.sh installs with them too. |
SIMCLUSTER_KEEP | 0 | 1 keeps the smoke cluster after the run. |
SIMCLUSTER_TEST_TIMEOUT | ten RATE_WINDOWs of the timing, at least 20m (20m in dev, 50m in production) | Timeout of TestSimclusterE2E in the smoke run, a Go duration. |
SIMCLUSTER_AIRGAP | 0 | 1 loads the k3s system images from the release’s air-gap bundle. |
SIMCLUSTER_FORCE_RELOAD | 0 | 1 makes every reload a reload.sh --force. |
COMPONENT (or C), NODE | (empty) | The component and the Node of simcluster-reload, simcluster-logs and simcluster-tree. |
The state directory holds kubeconfig; cluster.env (how the container was created); telemetry.env, spec.yaml, images.env, deploy.env and crds.sha (what is installed); values/ and render/ (generated values files, renders and configurations); bin/ (the binaries, with the host tools in bin/host); images/ (image tarballs); and runs/ (the run results).
Telemetry modes
A mode sets how the emulated Nodes’ power reaches Prometheus and which Joulie input settings read it. Every mode is checked against the emulator’s truth, read from the simulator’s /debug/emulate.
SIMCLUSTER_TELEMETRY | Path to Prometheus | Joulie settings beyond the defaults | Checked against |
|---|---|---|---|
otel (default) | the simulator pushes OTLP; Prometheus promotes k8s.node.name and host.name | none | CPU packages plus GPUs |
otel-unpromoted | as otel, without promotion | none: auto falls back to joining target_info | CPU packages plus GPUs |
otel-labels-unpromoted | as otel-unpromoted | PROMETHEUS_RESOURCE_ATTRIBUTES=labels | no data, with the promotion hint in the log |
otel-utf8 | as otel, names translated with NoUTF8EscapingWithSuffixes | PROMETHEUS_NAME_TRANSLATION=NoUTF8EscapingWithSuffixes | CPU packages plus GPUs |
otel-collector | the simulator pushes to an OpenTelemetry Collector, whose prometheus exporter Prometheus scrapes with honor_labels: true | none | CPU packages plus GPUs |
otel-bridge | the simulator pushes no OTLP and serves kepler-0.12, dcgm-4.8.4 and amd-dme-1.5.2; a contrib Collector runs examples/11-otel-collector-bridge/collector.yaml against them and pushes hw.* to Prometheus, the CPU as power gauges | none; the facility metrics stay off, as the chart has them | CPU packages plus GPUs, with the queries of Joulie’s otel preset |
kepler | the simulator also serves Kepler 0.12 series, GPUs included (SIM_KEPLER012_GPU=true) | NODE_POWER_PRESET=kepler | CPU packages plus NVIDIA GPUs |
kepler-legacy | the simulator also serves Kepler 0.7 series, scraped with honor_labels: false | NODE_POWER_PRESET=kepler-legacy | CPU packages plus NVIDIA GPUs |
With SIMCLUSTER_POWER_SCOPE=host, an otel mode other than otel-bridge, which carries no whole-host power, also sets NODE_POWER_SCOPE=host and is checked against whole-host power. Each check passes within 3 % of the truth, and never needs to be closer than 1 W. simcluster telemetry prints what a mode generates, for example go run ./simulator/cmd/simcluster telemetry --part prom-config --mode otel-utf8; the parts are prom-config, otelcol-config, sim-values, joulie-values, checks and wait.
Timing
Timing names map onto the real settings, so a nightly run in production timing exercises the values a real install uses.
| Timing setting | dev | production | Becomes |
|---|---|---|---|
CONTROLLER_RECONCILE_INTERVAL | 10s | 1m | controller manager RECONCILE_INTERVAL |
AGENT_RECONCILE_INTERVAL | 5s | 20s | agent RECONCILE_INTERVAL |
AGENT_SPEC_READ_TIMEOUT | 60s | 5m | agent SPEC_READ_TIMEOUT |
PROM_SCRAPE_INTERVAL | 5s | 1m | Prometheus scrape_interval |
OTLP_EXPORT_INTERVAL_MS | 5000 | 60000 | simulator otel.exportIntervalMillis |
RATE_WINDOW | 30s | 5m | controller manager PROMETHEUS_RATE_WINDOW |
FACILITY_POLL_INTERVAL | 10s | 30s | controller manager FACILITY_POLL_INTERVAL |
A rate window shorter than four export intervals reads too few samples.
What a real Prometheus needs
The harness configures its Prometheus the way a production one must be configured for Joulie’s OpenTelemetry inputs: --web.enable-otlp-receiver, k8s.node.name and host.name promoted onto the series, the translation strategy Joulie expects, and an out-of-order window for samples that carry the producer’s timestamps. The settings, the kube-prometheus-stack values (values/prometheus-grafana.yaml) and the pitfalls are under Prometheus settings and Pitfalls. The modes otel-unpromoted, otel-labels-unpromoted and otel-utf8 check what Joulie reads when the promotion is missing or the strategy differs.
The dev loop
make simcluster-up && make simcluster-deploy # once
# edit the agent
make simcluster-reload C=agent # rebuild, import, roll out; prints the seconds
make simcluster-logs C=agent NODE=intel-0
make simcluster-tree NODE=intel-0
make simcluster-status
- A reload follows the binary, not the sources. The image tag is the binary’s sha256, so
reload.shimports and upgrades only when the binary changed; an unchanged binary changes nothing, with no import, no upgrade and no restart. Touching a source file is therefore a no-op. To restart a component anyway, usereload.sh <component> --force(throughmake,SIMCLUSTER_FORCE_RELOAD=1), which sets the pod template annotationsim.joulie.io/reloadto the current time. - A reload rolls out one component. The others keep their images and pods even when shared code changed their binaries too; reload them, or
all. - A reload re-reads the charts and values files, reruns the release’s
helm upgradewith only the component’s tag changed, and re-applies the CRDs when they changed. The values files are those of the last deploy, asdeploy.envrecords them, a scenario’ssimulator.set(SIMCLUSTER_SIM_EXTRA_VALUES) included. - Another spec or mode.
up.shwith anotherSIMCLUSTER_SPECapplies its Nodes and deletes the ones it no longer names;deploy.shsets Prometheus up again when the mode or the timing changed. - Reading Prometheus and the truth, through the API server proxy:
export KUBECONFIG=~/.cache/joulie/simcluster/joulie-sim/kubeconfig
P=/api/v1/namespaces/monitoring/services/prometheus-operated:9090/proxy/api/v1/query
pq() { kubectl get --raw "$P?query=$(jq -rn --arg q "$1" '$q|@uri')" | jq -c '[.data.result[] | [.metric.k8s_node_name, .value[1]]]'; }
pq 'sum by (k8s_node_name) (rate(hw_host_energy_joules_total[30s]))'
kubectl get --raw /api/v1/namespaces/joulie-sim/services/joulie-telemetry-sim:80/proxy/debug/emulate | jq '[.nodes[] | {name, hostW: .host.powerW}]'
Deviations from a real cluster
- KWOK taint. Every fake Node carries the KWOK annotation and the taint
kwok.x-k8s.io/node=fake:NoSchedule, which keeps infrastructure pods off it. A pod meant for a fake Node must tolerate it. Placement ignores this taint, since a real Node does not have it. - The
sim.joulie.io/clusterselector. The infra Node runs a real kubelet, so a workload pod could land there. The simulator’s workload pods selectsim.joulie.io/cluster=<spec>by default, and nothing withoutjoulie.io/workload-classmay be bound to a fake Node (status.shchecks it). - Agents are Deployments, unprivileged. On a real node the agent is a DaemonSet pod with host network and privileges. An emulated agent is a Deployment on the infra Node with
privileged: falseand no host network, and itsNODE_NAMEis a literal. A privileged pod would see the infra host’s real devices on every fake Node, and only one host-network agent fits on a Node. /dev/nvidiactlis a file. On NVIDIA Nodes the tree has an empty regular file mounted at/dev/nvidiactl, where a privileged agent on a real node sees the driver’s character device. The agent only checks that it exists.- Tool stubs.
nvidia-smi,rocm-smiandamd-smiare links tofakesmi, first on the agent’sPATH.fakesmichecks what the real tool needs before it runs (the ELF loader, the C libraries and NVML fornvidia-smi, a script’s interpreter; see Simulator hardware emulation). A pool’stoolDeliveryunderspec.harness.poolssets what they find.mounted: the harness mounts one stub file at the first path of each requirement (seven on an NVIDIA Node, two on an AMD one), standing in for a node where the chart’s hooks deliver the driver; the stub is never run.none: nothing, so the tools fail as on a node without driver tools. A pool that does not set it follows the Joulie install:mountedonly withagent.runtimeClassNameoragent.extraVolumes, otherwisenone, as a default install has it. The specs intests/simcluster/specs/set it on every GPU pool. Only a profile whose tools list requirements can fail this way: a tool without requirements always runs. - With
SIMCLUSTER_HWEMU=files, writes and counters move once per tick. A write the driver would refuse, such as one to a RAPL limit the firmware locks, succeeds and reads back until the next tick, which reverts it and reportsjoulie.sim.write.rejectedwith the errno a real kernel returns at once aserror.type(enodatafor a locked RAPL limit,einval,ebusyand so on; see Events). The writes made between two ticks are applied in order of modification time, and two writes to one file count as the last.energy_ujchanges once perSIM_EMULATE_TICK, so a reader that turns energy differences into power over its own wall-clock interval is off by up to tick / interval per reading (20 % at a 1 s tick and a 5 s interval), without accumulating. The default,fuse, has none of these. - A simulator restart in FUSE mode. Until the new simulator serves its views, seconds after its pod starts, the agents’ reads of
/host-sysand/emu/statefail withENOTCONN; they recover without a restart (check E17). Each restart leaves the old views mounted underneath untilmake simcluster-down. - A root with directory limits is not resumed. A tree in which locked limits are directories, as an older simulator rendered them, stops the start with
ErrTreeConflict. Remove the root (make simcluster-downremoves the volume<name>-hwemu) and deploy again. - Fake Nodes have no kubelet. KWOK moves a pod bound to a fake Node through its phases (its stages, plus
scripts/simcluster/manifests/stage-*.yaml); no container runs, and the Node’s power is the simulator’s model of its load.
Checks
make simcluster-smoke runs TestSimclusterE2E (tests/simcluster, build tag simcluster) against the deployed cluster. Each check is one entry under assertions in the run’s summary.json, with its status, observed and expected values. The list of checks, and what each one asserts, is in tests/simcluster/README.md. The test changes the cluster as it goes (the caps go to 50 %, the extender is reloaded), so it expects a fresh deploy.
Run results
Each smoke run writes $SIMCLUSTER_DIR/runs/<UTC>-<spec>-<mode>-<sha12>[-dirty]/, and runs/latest points at the newest. down.sh keeps them.
summary.json, schemajoulie.simcluster.summary/v1:test.suite.name,test.suite.run.status(success,failure,timed_outoraborted),exitCode,failures,warnings,knownDefects(see the tests README), one entry per check underassertions(test.case.name,test.case.result.status, observed and expected), the spec, mode, scope and timing, the git commit and whether the tree was dirty, the images, and the semantic conventions schema URL.otlp/harness.metrics.jsonlandotlp/harness.logs.jsonl: one OTLP JSON object per line, with ajoulie.scenario.assertion.evaluatedlog record andjoulie.scenario.assertion.valuepoints per check.k8s/(the Nodes, NodeHardware, NodeTwins, pods and events at the end),logs/(every pod injoulie-system,joulie-simandmonitoring, k3s’s whole log ask3s.logand containerd’s ascontainerd.log, also from a container that exited),values.effective.yamlandvalues.simulator.effective.yaml,images.env,deploy.env,cluster.env, andsmoke.log, everything the run printed.
The scenario runner writes the same layout and schema, so one reader serves both.