Skip to content

v0.1.23 An alert for monitoring's own blind spot, a support bundle, and an RCA that investigates

Choose a tag to compare

@horryworks horryworks released this 08 Aug 11:57
· 21 commits to main since this release

📋 Full release notes: RELEASE_NOTES.md

Container images (published by CI to GHCR):

ghcr.io/horryworks/yagra-core:v0.1.23
ghcr.io/horryworks/yagra-poller:v0.1.23
ghcr.io/horryworks/yagra-web:v0.1.23

This release also moves :latest.


Breaking changes

  • An alert's node field is now its subject, and is no longer always a UUID. For a
    poller-pool alert it reads pool:<name>. This affects GET /api/v1/alerts, GET /api/v1/alerts/history, the /api/v1/stream/alerts frames and the MCP alert tools. Every
    response carrying an alert now also carries subject_kind (node | pool) and, for a named
    subject, subject_namebranch on subject_kind before treating node as a node id.
    • On history rows and in the MCP DTOs, node / node_id is null for a non-node subject rather
      than a made-up UUID.
    • POST /api/v1/alerts/ack now takes either node (unchanged) or subject, the alert's
      flat subject form. node is no longer required; sending neither is a 400 invalid_subject.
    • Node-oriented aggregates are unaffected and stay node-only: /api/v1/alerts/top-nodes and
      /api/v1/alerts/transitions never report a pool.
  • Four previously unbounded tables now declare a retention policy (ADR-040). Troubleshoot
    analysis runs and their findings, generated AI root-cause reports, and monitoring-gap records are
    pruned on a schedule; the interface map is declared kept-until-node-deletion, with its reason. The
    first three had grown without limit since they were introduced — migration 0026 says "no auto-trim
    yet" in as many words, and scheduled analyses have been writing to that table on a cadence since.
    • A new Diagnostic data window (default 90 days) in Settings ▸ System settings covers analysis
      runs and RCA reports. It is deliberately separate from "Report runs": a window's name must not
      silently govern a second kind of data.
    • Monitoring gaps follow the alert-linked window instead, because a gap explains an absence of
      alerts and is only readable beside the history it explains.
    • The interface map is not pruned by age, and the reason is in the policy table: an orphaned
      (node, ifIndex) row is a stale identity rather than old data, and last_seen only advances
      while interface collection is running — so an age-based sweep would erase the names and speeds
      of every interface on a node whose polling was merely paused. Those rows disappear with their
      node. The real fix belongs in the poller and is a separate change.

New Features

  • Yagra now alerts when a poller pool stops having a poller. This was monitoring's own blind
    spot. The alert engine reasons about poll results, so when a pool loses its last live poller
    there is nothing for it to reason about: the scheduler falls back to publishing jobs on a subject
    nothing is subscribed to, plain NATS discards them, and the nodes drift to unknown rather than
    down. An entire site stops being monitored and every dashboard stays calm. Until now the
    condition was visible only to someone already looking at Settings ▸ Pollers.
    • Delivered over your configured notification channels at critical severity, so an existing
      critical → PagerDuty routing rule reaches it, and closed automatically when a poller returns.
    • A pool must be uncovered for five minutes before it notifies — a poller announces its own
      departure, so an ordinary rolling restart raises the condition instantly and the debounce is
      what stops that paging anyone. Tune or disable it with
      YAGRA_POOL_COVERAGE_ALERT_AFTER_SECS (default 300, 0 = off).
    • Two new gauges regardless of that setting: yagra_pools_without_live_poller (unlabelled — the
      one to alert on, or to drive a scale-up from) and yagra_pool_nodes_without_live_poller{pool},
      which reports 0 for a healthy pool rather than disappearing.
    • Meraki-managed nodes are excluded, as they are from the Pollers page: core's org collector
      polls them, so they do not depend on a pool.
    • It is a full alert, not only a notification. It appears on Active alerts and in alert
      history, streams live, is acknowledgeable from your incident tool, and renders through your
      notification templates. Two new template variables come with it — subject_kind and an
      always-present subject_name — so a template can read correctly for both a device and a pool.
    • A group-scoped operator sees the pools their own nodes are polled by, which is exactly the
      person whose site went dark. Pools holding no node they can see stay invisible to them.
    • Two things it is deliberately not: it never rolls into a node's displayed state (it belongs
      to no node), and it cannot be muted — a mute names a node.
  • The MCP surface can now read Yagra's own configuration — get_config(kind=…). One tool over
    28 reads: thresholds, event rules and sources, notification channels and routing rules, profiles
    and collection templates, a node's collected metrics, classification rules, the MIB catalog, a
    node's URL/DNS check, discovery candidates and scans, Meraki orgs/networks/polling, forwarding
    destinations, report definitions and schedules, and the retention / adjacency / LLM / roles /
    OIDC / LDAP settings. This closes the ADR-042 read-parity backlog: every read the WebUI can reach
    is now reachable from /mcp except the four live SSE streams, which have no subscription
    transport.
    • Read-only does not mean readable-by-anyone. Each kind demands the same permission its REST
      counterpart does — manage-users for OIDC and LDAP, manage-config for fourteen of them, and
      view for the rest — so a Viewer is served the role matrix and refused the threshold ruleset
      from the same tool.
    • No stored secret is returned. A node's URL check reports whether a credential is bound
      (has_credential), never which one; the REST body is unchanged.
  • The LLM root-cause analysis can now look things up for itself. Previously it was handed a
    fixed set of facts — the alert, the dependents, the upstream chain, a signal timeline, recent
    config changes — and answered in one shot, so it could only reason about what had been decided in
    advance to include. It now gets the read-only MCP tools and asks: pull the interface series, check
    whether the poller was even up, read what syslog said, look at the threshold that fired.
    • It runs under the caller's own visibility scope, so a group-scoped operator's analysis
      cannot read a node they cannot see, and under a view-only tool allow-list — the write tools,
      run_analysis, run_rca and the audit log are all out of reach, checked per folded branch
      rather than per tool.
    • What it looked up is stored with the answer (transcript on the report body) and replayed
      on both surfaces, for the same reason the evidence always was: an explanation whose reader
      cannot check what it was based on is an assertion.
    • Bounded by turns (YAGRA_RCA_MAX_TURNS, default 6), wall clock
      (YAGRA_RCA_TASK_BUDGET_SECS, default 240) and total tool output. Hitting a bound returns the
      model's last answer rather than failing the request. Set YAGRA_RCA_MAX_TURNS=1 to get the
      previous single-shot behaviour back exactly
      — no tools are offered and the request sent to the
      provider is byte-identical to before.
    • Tool output is device-supplied text and is fenced as such: a syslog line that says "ignore your
      instructions" arrives inside the same untrusted-output markers a device's sysDescr always did.
  • Support bundle (Settings ▸ System Health). One download containing everything needed to
    diagnose a deployment from outside it: which binary is actually running (image source ref and
    build profile, not just the version), every system-health section, the allow-listed environment,
    applied migrations with their checksums, per-table sizes and connection counts, active alerts, the
    audit tail, core's Prometheus scrape, and core's own rotated log files. Also at
    GET /api/v1/system/support-bundle?since_hours=N.
    • It is built for a site where nobody can open a shell and data does not leave casually, so the
      archive is designed to be reviewed before it is released: every entry is JSON or plain text,
      and MANIFEST.json lists what is carried and what is deliberately left out, with reasons.
    • Secrets are handled two ways. The environment is carried by an allow-list — a deny-list of
      password-shaped names would miss the credential inside YAGRA_DATABASE_URL's userinfo, which is
      the one that actually ships. Then every assembled byte is scanned, and a match aborts the
      export
      rather than redacting it: the strongest rule is the set of literal secret values the
      core process can see in its own environment, so a credential arriving through an unanticipated
      path is caught too. A refusal answers 500 support_bundle_redaction_failed naming the file and
      the rule, never the value.
    • It requires ManageConfig + ManageCredentials + ViewAudit — all three, so this cannot become
      a way to read the audit log or the credential report through an endpoint whose name mentions
      neither. In practice that means Admin.
  • Core's log is now written to disk as well as stdout. Hourly JSON-lines files under
    YAGRA_LOG_DIR, YAGRA_LOG_RETAIN_HOURS of them (default 48, pruned automatically), on a named
    volume so they outlive the container. Reading docker logs needs a shell on the host, which is
    exactly what a locked-down deployment does not grant — so a panic or an OOM used to leave nothing
    retrievable. A support bundle taken after a recovery now carries the run that died.
    • On by default in docker-compose.yml and docker-compose.deploy.yml; set YAGRA_LOG_DIR
      empty in .env to turn it off. Writes are non-blocking and drop rather than stall the poll loop,
      and an unwritable directory degrades to stdout-only with a warning instead of failing startup.
    • Pollers can opt in the same way (the image has the directory), but no compose file mounts one:
      the support bundle carries core's logs only. A poller's log body would have to cross the bus to
      reach one, which is a new bus message rather than a read. Poller heartbeat counters, poll-loop
      statistics and host resources are in the bundle already.
  • incident_correlate now correlates across topology neighbours. An incident is assembled from
    a node and its directly-linked upstream/downstream peers when their signals coincide in time
    (within five minutes), so a failed uplink reads as one incident with its downstream peers named
    instead of a row of unrelated single-node findings. Each finding lists the corroborating
    neighbours, and a peer's entries in the timeline are labelled with the node they came from.
    • It uses the auto-derived connectivity graph and hand-authored parents, regardless of the
      topology mode. The mode gate exists because a wrong derived edge would suppress a real alert
      and silence is unrecoverable; a diagnostic that names an extra peer only errs toward noise. So
      this works on a deployment still in manual mode, which is the default.
    • A node still needs a signal of its own to produce a finding — neighbours corroborate, they do
      not manufacture. Peers are capped per finding, and a node whose alerts are opted out of
      suppression is not reasoned about as anyone's upstream.
    • An incident spanning two devices produces a finding on each, naming the other. Both devices are
      affected, and the per-node attribution keeps the report's node counts honest.

Improvements

  • Troubleshoot's passive-event analyses read the event log store when one is configured. They
    had been reading PostgreSQL directly, which holds only alert-linked rows once VictoriaLogs is
    enabled (ADR-024) — so they were answering about the subset of events that had already alerted.
    rule_gap was the extreme case: its entire purpose is finding high-volume unmatched events,
    and unmatched events never reach PostgreSQL on a log-store deployment, so it was structurally
    guaranteed to return nothing on exactly the deployments that generate enough syslog to need it.
    • event_storm, severity_shift, auth_probe and incident_correlate's event lane now count
      the full firehose rather than the alert-linked subset. The MCP event_stats tool, which was
      built from three of the same queries, inherits the corrected answer.
    • event_flap is unchanged and was already complete: every action it counts is alert-linked, so
      PostgreSQL keeps all of them either way. That is now pinned by a test rather than a comment.
    • A log-store failure fails the analysis rather than falling back to PostgreSQL. Falling back
      would answer from the subset again with nothing to say so, which is the defect being fixed.
  • Unmatched events now cluster on the device's own event code. The Troubleshoot Rule gap
    analysis (and MCP event_stats) grouped events by trap OID, else syslog APP-NAME. A large class
    of real network gear supplies neither — its timestamp format falls outside both RFC 3164 and
    RFC 5424, so the datagram parses as raw text and no APP-NAME is extracted — and such a device can
    emit six figures of events a day while producing zero rule-gap findings. "0 gaps" reads as
    "nothing is unrouted" when it actually meant "not measurable". Yagra now lifts the vendor's own
    code out of the message at ingest (%%01URL/4/FILTER(l):URL/4/FILTER, %LINEPROTO-5-UPDOWN:
    LINEPROTO-5-UPDOWN, a leading SNMP_TRAP_LINK_DOWN:) and clusters on it.
    • The extracted code is always a verbatim slice of the message, so a signature named in a
      finding can be pasted straight into a substring event rule and will match the events — which
      is the action a rule gap exists to prompt. Rules are matched in-process against the message
      text, so this holds on every deployment.
    • ⚠️ Free-text search for a whole signature is not guaranteed on a VictoriaLogs deployment.
      LogsQL matches whole tokens, so a vendor prefix that runs into the code — %%01ATK/4/…
      tokenizes with 01ATK, not ATK — will not match on the leading segment. Search the
      distinctive tail instead (FIREWALLATCK). PostgreSQL-only deployments match on substrings and
      are unaffected; this is the same backend difference already documented for plain search terms.
    • Clustering precedence is trap OID → device event code → APP-NAME. Deployments whose devices
      already send an APP-NAME keep working; a device that sends both now clusters on the more
      specific code, so an existing rule-gap finding may split into several finer ones.
    • Extraction applies to newly received events only — there is no backfill and none is needed, as
      the analysis reads only unmatched events and those age out within one retention window.
  • GET /api/v1/mib-catalog accepts limit and is now bounded. The query had no row cap on
    either edge, which was survivable while its only caller was a settings screen and stopped being so
    once an AI client could ask for the whole table. limit is 1–2000 and defaults to 2000, so an
    existing caller sees no change unless the catalog holds more than that.
  • MANIFEST.json reports both size caps that can bite — log files dropped for size, and files
    outside the requested window — because a silently truncated log reads as "nothing was logged",
    which is a wrong answer rather than a missing one.
  • The redaction report carries secret_literals_skipped_short beside
    secret_literals_enforced, and README.txt interprets the pair in words. On its own, an
    enforced count of zero has three meanings a reviewer cannot tell apart: this deployment holds
    no secrets in its environment, it holds some that were too short to enforce safely, or the
    collection broke. The first is a clean bill of health and the third is the strongest rule in the
    scan silently not running. Found on the first real bundle, where establishing which it was took
    a shell session on the deployment — the exact work a support bundle exists to remove.
    • The eight-character floor is not lowered: a lab POSTGRES_PASSWORD=yagra would forbid the
      substring yagra, which appears in every path and table name in the archive, and the scan
      would refuse every bundle forever. Counting the declined values is the fix; the README says
      plainly when the scan has fallen back to pattern matching alone, and what to do about it.
  • A group-scoped rule_gap or auth_probe restricts at the store rather than afterwards. Both
    used to group fleet-wide and then keep a row only if its representative node was in scope, so a
    signature genuinely occurring inside your group vanished whenever some node outside it happened to
    sort lower. auth_probe additionally hid every auth-failure source that mapped to no inventory
    node — which is exactly what an external prober looks like. Expect more rows than before; the
    extra ones were always yours.
  • PUT /api/v1/settings/retention gained diagnostic_days. It is optional, so a client sending the
    previous four-field body keeps working — but note that such a body is a full replace and therefore
    resets this window to the default (90). The WebUI always sends every field.

Bug Fixes

  • db/connections.json no longer reports a negative connection age. now() is the transaction's
    start time and the backend running the query sets its own state_change after it, so the
    active row reliably came back a few milliseconds below zero — an artefact of measuring from
    inside, not a fact about the deployment.

Security

  • An OIDC login is now refused when the IdP delivers the groups claim out-of-band — Microsoft
    Entra's "group overage", where a user in more than roughly 200 groups gets _claim_names /
    _claim_sources (or hasgroups) instead of their groups. Yagra reads groups from the ID token
    only, so it previously saw an empty group list and fell through to the provider's default_role.
    That is a silent role change, not a failure: an administrator signed in as whatever the
    default was, and where the default is Admin, a user who should have been a viewer signed in as an
    administrator. Neither left any trace an operator could find.
    • The client still receives the same generic 401 as every other SSO refusal — the callback
      deliberately does not tell a stranger which step failed. The audit log records
      auth.oidc.group_overage with the username (reaching this branch requires a verified ID
      token, so there is no prober to help), and the core log names the claim and the remediation.
    • ⚠️ If your tenant relies on this fallback, affected users will stop being able to sign in.
      Configure the app registration to emit only the groups assigned to it, or use a group-filtering
      claim. LDAP role mapping is unchanged.