Nodes
PartialA node is the edge runtime that collects from and controls gear wherever it sits: it pulls its worklist from the server, runs it on the spot, and ships results back. This page covers worklist pull, placement, tasks and commands, sessions, inbound demux, the task queue, reachability, and shipping telemetry; the declarative shape it executes lives in templates and collection.
The node
Section titled “The node”The node is the edge process (omniglass node run), one per site, or the server itself when no site-local edge exists (see Placement). Identity is bound to node.name (a compromised node cannot impersonate another, identity-access); it holds no config, and its writes are confined to its placement-derived visible_set (the owners of its assigned tasks): node mode, not all-visibility system mode.
A node carries the identity triad with one exception. Its id is principal_id, the immutable primary key every reference stores (a node is the detail row of a principal, and interface.node_name holds that uuid whatever its column name suggests). Its name is the operator-typed identifier and fleet address, the NATS subject token and the enrollment identity, and it is the one name with no :rename custom method: moving it would move a live subject, so the PATCH body carries no name at all. Its label is the operator label (console falls back to the name). Its optional location is descriptive, not a scope: a node stays fleet-wide, location clearing if that location is deleted (ON DELETE SET NULL). Descriptive does not mean unguarded: the create and the update resolve that reference within the caller’s own location:read scope, the seam every other placement bind uses, and a location outside it is refused as absent rather than as forbidden (ADR-0089, amended by #705). The console blade is read-edit-save via PATCH /nodes/{name} (Edit primary, gated node:update, editing label, description, location; name read-only; enrolling secondary).
A node is a taggable owner: governed tags whose applies_to includes node bind to it (fleet-wide, all-scope, node:update); effective tags are the platform layer plus direct bindings, no cascade (a node is not a scope tree); the blade carries a Tags panel, the list a Tags column and per-key facet. Decommissioning (DELETE /nodes/{name}, node:delete) hard-deletes, cascading its interfaces, their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential; collected component telemetry is untouched.
Getting its instructions
Section titled “Getting its instructions”The node pulls a worklist (the tasks and commands resolved for the components placed on it) over a NATS request-reply config pull, and heartbeats separately on its own subject (the protocol), so liveness tracks independently of the pull.
Config propagation (declared change to running node)
Section titled “Config propagation (declared change to running node)”The node-server protocol
Section titled “The node-server protocol”The edge is outbound-only: a node sits behind NAT, so the server never dials it. A node is a NATS client over the WAN, one authenticated outbound connection (an nkey/JWT credential bound to node.name); everything server-to-node arrives on subjects the node is permitted to consume. Three flows share the connection:
Buffering and retention are cascade settings
Section titled “Buffering and retention are cascade settings”Credentials at the edge
Section titled “Credentials at the edge”Enrollment
Section titled “Enrollment”A node is created server-side first (its node.name and properties), the UI mints a per-node enrollment token, and the node claims its identity on first connect, exchanging the token for its NATS credential (a per-node JWT signed for its nkey, scoped to the subjects its placement allows, identity and access).
Placement (ETL, cascaded)
Section titled “Placement (ETL, cascaded)”Running tasks
Section titled “Running tasks”The built interface types, their per-task params, and the fixed samples each emits are the collection type catalog (interface types and their config); this page covers how the node executes them: reachability gating, sessions, the task queue, tick scheduling.
Sessions
Section titled “Sessions”Inbound handling on a shared connection
Section titled “Inbound handling on a shared connection”Target design: the ordered matcher set, tracked in #489
One connection carrying heterogeneous inbound frames cannot assume an arriving frame answers the
last command. Frames route through an ordered matcher set: every task
contributes a matcher (a poller’s awaited-response shape, a listener/stream’s match: predicate),
each frame tested in order, first match winning. While a poll is outstanding its response
matcher is tried first, then the standing matchers in declared order, so an event arriving mid-poll
falls through to its stream instead of being mis-eaten as the response; where the protocol itself
frames responses vs events (xAPI *r vs *e, a request id), framing drives routing and the regex
only extracts within the matched frame. An unmatched frame lands as raw (orphan, logged): a
missing matcher surfaces, never fails silently.
The component task queue
Section titled “The component task queue”Target design: the component task queue, tracked in #489; the durable command queue is ADR-0036
The node’s work is the component task queue (distinct from the central rule engine;
workers): poll tasks (produce samples) and command tasks (from
run actions, producing a caused event + action-row status), split by shape:
- discrete tasks (pollers, commands): request/response, serialized into per-component lanes. Component, not host, is the contention key: a server with two IPs is one component, and a reboot takes out both interfaces. A shared poller runs once on its parent and fans out at binding.
- standing receivers (listen tasks): always-on, event-driven, not lane-serialized, sharing a held session with pollers (demuxed) or owning their connection.
Smart-wait gate. After a disruptive command the lane blocks until reachability reports the host back up, read from the node’s local copy, not the sample store; a fixed timeout backstops.
Tasks within one interface run serially (one probe, then its tasks in order); only distinct interfaces run concurrently.
Whether to add intra-interface concurrency, given that connection and order semantics differ per protocol.
The node-side queue is not durable: durability lives server-side (the JetStream command
queue, the cascade-configurable telemetry buffer). On reconnect the node re-pulls its worklist,
resumes its durable consumer on the command queue, and replays unacked telemetry publishes
(idempotent on (series, ts)).
Implicit reachability
Section titled “Implicit reachability”Any interface with a host address gets reachability for free: the node pings the host and checks the declared port(s), continuously and out of band; a smart default, bypassable per interface (for endpoints that drop ICMP or have no port).
Target design: the layered availability gate, tracked in #489
The results come back as reachable / port_open samples usable in rules and dashboards, and
feed the smart-wait gate from the node’s local copy: the connection detector and the dashboard
signal are one always-on probe.
The layered availability gate is an OSI-layered set of cheap checks run as a concurrent
pre-pass (high concurrency, short timeouts) before a connection-interface’s poll tasks. All
applicable checks run, each shipping a built-in sample, instanced (the ping by host, the rest by
interface) and owned by the queried component; the interface’s interface-reachable verdict is
their AND.
| Layer | Check | Sample | Notes |
|---|---|---|---|
| L3 network | ICMP ping, batched once per host per tick | icmp-reachable / icmp-rtt-avg | informational (see verdict below); shared by every interface on the host |
| L4 transport | TCP connect (tcp-family) or UDP presence (snmp/UDP) | tcp-open/tcp-connect-time · udp.open | a closed UDP port answers ICMP port-unreachable, so absence of that is “present”; this is why SNMP’s transport check is L4, not its auth-dependent get |
| L7 app | protocol handshake: SNMP sysUpTime get (snmp.reachable, default-on) · SSH handshake+auth · telnet login chain | (verdict) | the SNMP get is the primary, default SNMP liveness (ICMP-independent); SSH/telnet are opt-in (ssh_check/telnet_check="on") because their liveness credential can differ from the device’s |
The verdict respects each layer’s definitiveness. A TCP connect and any L7 handshake are
definitive, so the ping is informational (an ICMP-filtered host still reads up); a UDP
“present” is a read timeout (open|filtered), ambiguous, and only the ping disambiguates it, so a
failed ping fails the verdict ONLY for an SNMP interface opted out of the L7 get (snmp_check=off),
the UDP probe its only signal (pingGates). A definitively down layer (TCP refused, UDP
ICMP-unreachable, an L7 auth/no-answer) fails the verdict regardless; an inconclusive probe (a
setup/resolve error, a missing credential) does not gate.
Off gates. Every check toggles via params.<name>_check = "on" | "off"; params.liveness = "off" disables the whole gate. Defaults split by auth dependence: auth-independent checks ON,
opt-out (ping_check, port_check, tls_check when TLS lands); snmp_check ON, the one
auth-dependent exception (the get reuses the poll’s community, so a failure means genuinely
unpollable, and it is the only ICMP-independent SNMP signal); ssh_check / telnet_check OFF,
opt-in (a differing liveness credential must not read as down).
The honest limit: a v2c wrong community is a silent drop, so a get failure alone cannot separate
down from wrong-community. Cross-referencing does: host pings + UDP not refused + get silent =
“reachable, SNMP not answering this community”; with ICMP fully blocked the inference is lost,
reading “host down or fully filtered.” SSH verifies auth; telnet completes the login:/Password:
chain (service-up, not a verified shell). params.liveness_oid overrides the probe OID.
Poller tasks run only if the verdict is up; listener (mode=listen) tasks run ungated,
never pinged; inline probes (icmp/tcp with the host on the task) are the check, ungated. A
down interface’s gate samples ship in one batched call. L5 (socket), L6 (TLS), and further L7
handshakes extend the stack: one append in ifaceChecks, gated by its own _check param.
Shipping samples
Section titled “Shipping samples”The node ships a native TelemetryBatch: { samples, labels } plus an envelope (task, batch ts), published to the JetStream raw ingress subject (protobuf-encoded, the proto surviving as the NATS message schema).
Target design: the edge retry buffer, raw on failure, and the OTLP adapter, tracked in #430
The publish is buffered with retry/backoff. On a parse or validation failure the node also ships
the raw wire bytes so the server can emit a collection.failed event; on success raw is omitted
(there is no telemetry table), unless a dev raw-mode is on. An OTLP adapter at the edge
accepts OTLP from third-party tools and translates to the native shape.
Samples are already produced at the edge; an admission consumer binds owner (registry lookup, owner attribution against the node’s placement) at consume time and republishes to the trusted stream the rule engine and persistence read, so a forged owner drops before evaluation, not at the durable write. The server never re-derives observed samples; the node’s job ends at the ship.
Tick scheduling, concurrency, and self-observability
Section titled “Tick scheduling, concurrency, and self-observability”Target design: the tick scheduler, node.self, and the node-down sweep, tracked in #489 and #430; the seeded node rules are the event_rule (ADR-0050)
A tick groups the worklist by interface and runs three phases: the L3 ping pre-pass (batched per
host), the gate-verdict pre-pass, the poll phase. The gate pre-passes run at a high fixed
concurrency (gateConcurrency), the poll phase across the bounded poll pool (default 16,
--workers), so the cheap gate is never throttled by a small --workers and a node facing many
dead targets is bounded by concurrency, not the serial sum of probe timeouts (a dead SNMP get costs
timeout * (retries+1), via --snmp-timeout / --snmp-retries, default 3s x2). Each poll task gets
a per-task deadline (--task-deadline, default 30s).
Per-task schedule dispatch: the resolved interval exists, but honoring distinct per-task cadences
within one node tick is unsettled.
The loop is overrun-aware: it reschedules relative to each tick’s finish, not a fixed ticker; a tick exceeding its interval is flagged and the next fires immediately, so falling behind surfaces rather than silently dropping ticks.
Each tick the node publishes a node.self envelope: tick duration, task
attempted/ran/skipped/failed counts, interface probed/up/down counts, the node.overrun state. Not
special-cased: node.self is node-owned samples (the seeded node.* types) riding the same
raw-ingress -> admission -> trusted path; the self shape is built into the binary (no
operator-authored template), the admission consumer binds it to the reporting node
(owner_kind = node, the node_id arm of the exclusive arc), and the rule engine’s batching and
amortized rule refresh apply for free. Self-telemetry is best-effort (a failed report is logged,
never fatal).
A node that goes dark reports nothing, so a node-liveness sweep runs server-side: a node whose
last heartbeat (or registration, if it never checked in) predates OMNIGLASS_NODE_DOWN_AFTER
(default 90s) gets a node-owned node-down alarm, auto-resolved on the next heartbeat. The
sweep raises it directly (no event_rule: a dead node emits no sample), keyed by (node-down, node owner) for idempotency across sweeps; this is why the node owner arc reaches event and
alarm, not just samples.
A degraded-but-alive node alarms through the ordinary event_rule path: a rule on a node.* key
opens a node-owned alarm. Two are seeded, node-overrun (while node.overrun is true) and
node-tasks-failing (while node.tasks.failed > 0), both resolving implicitly on the next clean
tick, because Evaluate is owner-general (component, system, location, or node), which also unlocks
system- and location-owned alarms.