Skip to content

Why CUE

If you’ve maintained a Nagios estate past a few hundred hosts, you already know the pain this page is about: use chains four templates deep, a typo in a directive that Nagios accepts and just silently ignores, and no way to know a config is broken until -v catches it (or doesn’t).

The problem: template inheritance without a type system

Section titled “The problem: template inheritance without a type system”

Nagios configuration is templates (use) resolved by an ad hoc, positional inheritance algorithm: ordered multiple inheritance, +value to append, null to unset, register 0 for abstract templates. It works, but it’s untyped — a misspelled directive is either silently ignored or a runtime surprise, and there’s no way to ask “is this config valid?” without loading it into the running daemon.

CUE is a typed configuration language built around unification rather than inheritance. Two partial definitions of the same value merge — they don’t override each other in some template resolution order. That’s a better fit for “shared defaults, per-host overrides” than use chains ever were, and it comes with:

  • A real schema. jaque’s config schema defines #Host, #Service, #Check and friends as closed structs — an unknown field like chekc_interval (typo) is a build error naming the exact field, not a directive Nagios quietly drops.
  • Validation as part of the language, not a separate linter bolted on after the fact. Type constraints (string & !=""), enumerations ("OK" | "WARNING" | "CRITICAL" | "UNKNOWN"-shaped disjunctions), and numeric ranges live right in the schema.
  • Diffable, GitOps-friendly text. A CUE file is still just text you git diff, review, and merge — nothing is generated by a UI that only the UI can read back correctly.

Config validation happens in two passes, and they fail differently:

  1. Constraints, at build. CUE evaluates the object graph against #Schema — types, required fields, closed-struct field names, disjunction shape (a check’s type picks which of #TCPCheck, #HTTPCheck, … applies). This is where a typo or a missing address gets caught, before the config graph is even fully concrete.
  2. Concretion, at validate. Even a config that satisfies every constraint can still be incomplete — CUE lets you leave values as open disjunctions or references that never got resolved to a concrete value. jaque -config x.cue calling into config.NewConfig forces full concretion; anything still ambiguous at that point fails there, with the field’s path in the error.

Practically: run jaque -config yourfile.cue and read the error. It names the field, not just “config invalid.”

The Nagios idiom of a generic-host template three levels of use deep becomes a CUE template you anchor structs on:

#CastDevice: #Host & {
parents: ["router"]
check: type: "tcp"
}
hosts: chromecast: #CastDevice & {
address: "192.168.1.200"
check: address: "192.168.1.200:8009"
}

#CastDevice isn’t inherited from — it’s unified with. Two Cast devices sharing that template can’t drift into “wait, which one applied the override” the way a use chain can. Anchoring on #Host matters: a bare #CastDevice: {...} template would be implicitly closed and reject address/check as unknown fields at the use site — anchoring on the schema keeps it open the way the schema itself is. See CUE in practice for a full config walkthrough.

CUE as the config language passed an explicit reversal criterion, not a settled bet made lightly: ADR-003’s Phase 0 spike had to model 3 hosts / 10 services with a shared template and one override, loaded and validated from Go, or the decision reverted to YAML + JSON Schema. It passed, and CUE is the one in place today.