diff options
| author | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 10:13:13 -0500 |
|---|---|---|
| committer | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 10:13:13 -0500 |
| commit | a7f38335e5fed5a98bfaad64ab40544124337224 (patch) | |
| tree | d70221c4093fbe15a428d407893e740ef05711fc /lgtm.nix | |
| download | telemetry-flake-a7f38335e5fed5a98bfaad64ab40544124337224.tar.xz telemetry-flake-a7f38335e5fed5a98bfaad64ab40544124337224.zip | |
initial commit
Diffstat (limited to 'lgtm.nix')
| -rw-r--r-- | lgtm.nix | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/lgtm.nix b/lgtm.nix new file mode 100644 index 0000000..a4144ec --- /dev/null +++ b/lgtm.nix @@ -0,0 +1,534 @@ +{ pkgs, config, lib, ... }: + +let + # The hostname the container uses to reach the host's exporters. + hostName = "poweredge"; + + # One scrape interval to rule them all. This MUST match the Grafana Prometheus + # datasource's `timeInterval` below, otherwise Grafana computes $__rate_interval + # from the wrong assumption and every rate()-based panel intermittently renders + # "No data". (Prometheus' own default is 1m, which is what broke the Node Exporter + # Full dashboard: $__rate_interval resolved to 60s, so rate(x[60s]) over 60s-spaced + # samples usually saw fewer than the two samples rate() requires.) + scrapeInterval = "15s"; + + # Where the host's zpool collector drops its metrics for node_exporter to pick up. + textfileDir = "/var/lib/prometheus-node-exporter-textfile"; + + ports = { + node = 9100; + smartctl = 9633; + loki = 3100; + prometheus = 9090; + grafana = 3000; + }; + + # Stable datasource UIDs, referenced by the dashboards below. + dsUid = { prometheus = "prometheus"; loki = "loki"; }; + + ########################################################################## + # Dashboard plumbing + ########################################################################## + + # Dashboards exported from grafana.com carry an `__inputs` block and reference + # their datasource as "${DS_PROMETHEUS}". That placeholder is only substituted by + # Grafana's interactive *import* flow — file-based provisioning never runs it, so + # the uid stays literal and every panel fails to resolve a datasource. This filter + # performs the substitution at build time, using our fixed datasource UIDs. + dashboardFixup = pkgs.writeText "grafana-dashboard-fixup.jq" '' + def dsuid($pluginId): + if $pluginId == "prometheus" then $prom + elif $pluginId == "loki" then $loki + else "" end; + + def subst($name; $uid): + walk(if type == "string" then gsub("\\$\\{" + $name + "\\}"; $uid) else . end); + + reduce (.__inputs // [])[] as $i (.; subst($i.name; dsuid($i.pluginId))) + | del(.__inputs, .__requires) + # Provisioned dashboards must not carry an id, or Grafana rejects them on + # collision with whatever already occupies that id in its database. + | .id = null + ''; + + fixupDashboard = name: src: pkgs.runCommand "grafana-dashboard-${name}.json" + { nativeBuildInputs = [ pkgs.jq ]; } + '' + jq --arg prom ${lib.escapeShellArg dsUid.prometheus} \ + --arg loki ${lib.escapeShellArg dsUid.loki} \ + -f ${dashboardFixup} < ${src} > "$out" + ''; + + # grafana.com dashboard, pinned by revision. + mkGrafanaComDashboard = { name, id, rev, sha256 }: + fixupDashboard name (pkgs.fetchurl { + url = "https://grafana.com/api/dashboards/${toString id}/revisions/${toString rev}/download"; + inherit sha256; + }); + + dashboards = { + # https://grafana.com/grafana/dashboards/1860 — Node Exporter Full + node-exporter-full = mkGrafanaComDashboard { + name = "node-exporter-full"; id = 1860; rev = 45; + sha256 = "sha256-GExrdAnzBtp1Ul13cvcZRbEM6iOtFrXXjEaY6g6lGYY="; + }; + + # https://grafana.com/grafana/dashboards/22604 — Smartctl Exporter + smartctl-exporter = mkGrafanaComDashboard { + name = "smartctl-exporter"; id = 22604; rev = 2; + sha256 = "sha256-ci8WE23fZ+ltEKFoUdNNVXsUIV0jqtas79ia2lYIo88="; + }; + + # Dashboard 15362 ("ZFS Pool Metrics") used to live here. It is an *InfluxDB* + # dashboard — its queries are InfluxQL against a `zpool_stats` measurement — so + # it can never render against Prometheus, no matter how it is provisioned. This + # is a Prometheus-native replacement built on the metrics we actually export: + # node_exporter's zfs collector plus the zpool textfile collector below. + zfs-pools = fixupDashboard "zfs-pools" ./dashboards/zfs-pools.json; + + # CrystalDiskInfo-style per-drive SMART view. Generated by + # ./dashboards/gen_smart.py; the Good/Caution/Bad verdict it displays comes + # from the recording rules in ./rules/smartctl.yml. + smart = fixupDashboard "smart" ./dashboards/smart.json; + }; + + ########################################################################## + # ZFS metrics (host) + ########################################################################## + + # node_exporter's zfs collector reads /proc/spl/kstat/zfs, which gives us ARC + # statistics, per-dataset objset counters and pool state -- but nothing about + # capacity, fragmentation, vdev errors, scrub history, compression, encryption or + # snapshots. OpenZFS 2.2+ also no longer publishes the per-pool `io` kstat, so + # node_zfs_zpool_* is largely empty. Everything else comes from the zpool/zfs CLIs + # via ./collectors/zfs-metrics.py, scraped through the textfile collector. + # + # The wrapper exists only to put the matching zfs userland on PATH; the collector + # itself is a plain script so it can be run and tested by hand. + snapshotLabels = [ "frequent" "hourly" "daily" "weekly" "monthly" ]; + + retentionArgs = lib.optionals config.services.zfs.autoSnapshot.enable ( + lib.concatMap (label: [ + "--retention" + "${label}=${toString config.services.zfs.autoSnapshot.${label}}" + ]) snapshotLabels + ); + + zfsCollector = pkgs.writeShellApplication { + name = "zfs-textfile-collector"; + runtimeInputs = [ config.boot.zfs.package ]; + text = '' + exec ${pkgs.python3}/bin/python3 ${./collectors/zfs-metrics.py} \ + --output ${textfileDir}/zfs.prom ${lib.escapeShellArgs retentionArgs} + ''; + }; + + # smartctl_exporter reads only the entry *count* out of each drive's self-test log + # and discards the log table, so it cannot say when a drive last ran a short or + # extended test, or whether it passed. This parses `smartctl -j -l selftest` + # directly. Device labels are bare names ("sda") so they join against the + # exporter's own series. + smartSelftestCollector = pkgs.writeShellApplication { + name = "smart-selftest-collector"; + runtimeInputs = [ pkgs.smartmontools ]; + text = '' + exec ${pkgs.python3}/bin/python3 ${./collectors/smart-selftest.py} \ + --output ${textfileDir}/smart-selftest.prom + ''; + }; +in +{ + ########################################################################## + # Host: log shipping + ########################################################################## + + services.alloy.enable = true; + + environment.etc."alloy/config.alloy".text = '' + loki.source.journal "journal" { + forward_to = [loki.write.default.receiver] + relabel_rules = loki.relabel.journal.rules + max_age = "12h" + } + + loki.relabel "journal" { + forward_to = [] + rule { + source_labels = ["__journal__systemd_unit"] + target_label = "unit" + } + rule { + source_labels = ["__journal__hostname"] + target_label = "host" + } + rule { + source_labels = ["__journal_priority_keyword"] + target_label = "level" + } + } + + loki.write "default" { + endpoint { + url = "http://lgtm:${toString ports.loki}/loki/api/v1/push" + } + } + ''; + + ########################################################################## + # Host: telemetry sources + # + # These have to live on the host, not in the container: they read host + # hardware and kernel state (/proc, /sys, SMART, ZFS ioctls) that a + # container has no view of. Only the web services (Grafana, Prometheus, + # Loki) run in the container. + ########################################################################## + + services.prometheus.exporters.node = { + enable = true; + port = ports.node; + listenAddress = "0.0.0.0"; + + # NOTE: enabledCollectors *adds* to node_exporter's defaults — cpu, diskstats, + # filesystem, meminfo, zfs, hwmon, pressure and friends are already on, so + # listing them here did nothing. Only genuinely opt-in collectors belong here. + enabledCollectors = [ + "systemd" # node_systemd_* — the "Systemd" row + "processes" # node_processes_* — "Processes Detailed States", "PIDs", "Threads" + # "interrupts" # fills the "IRQ Detail" panel, but emits one series per + # # (cpu, irq) pair — thousands of series on a many-core box. + # # Enable only if you want that one panel badly enough. + ]; + + extraFlags = [ + "--collector.textfile.directory=${textfileDir}" + # node_systemd_unit_start_time_seconds, so the dashboards can show when the + # zfs-snapshot-*/zfs-scrub units last ran rather than only whether they failed. + "--collector.systemd.enable-start-time-metrics" + ]; + }; + + # Upstream hardens the unit with ProtectHome=true, which makes /home look empty and + # blanks out the filesystem panels for any dataset mounted under it. read-only still + # allows statfs while keeping the sandbox. + systemd.services.prometheus-node-exporter.serviceConfig.ProtectHome = lib.mkForce "read-only"; + + services.prometheus.exporters.smartctl = { + enable = true; + port = ports.smartctl; + listenAddress = "0.0.0.0"; + # Disks are direct-attached (AHCI/HBA), so auto-discovery via `smartctl --scan` + # finds them; no explicit `devices` list needed. Throttle how often we actually + # shell out to smartctl — SMART data changes slowly and polling spins up disks. + maxInterval = "60s"; + }; + + # SMART self-test history. Reading the log does not spin an idle disk up, but the + # data only changes when a test finishes, so this runs far less often than the + # exporter scrapes. + systemd.services.smart-selftest-collector = { + description = "Collect SMART self-test history for the Prometheus node exporter"; + serviceConfig = { + Type = "oneshot"; + ExecStart = lib.getExe smartSelftestCollector; + User = "root"; + StateDirectory = "prometheus-node-exporter-textfile"; + StateDirectoryMode = "0755"; + UMask = "0022"; + TimeoutStartSec = "180s"; + + # Same shape as the smartctl exporter's own hardening: it needs raw device + # access to talk to the disks, and nothing else. + AmbientCapabilities = [ "CAP_SYS_RAWIO" "CAP_SYS_ADMIN" ]; + CapabilityBoundingSet = [ "CAP_SYS_RAWIO" "CAP_SYS_ADMIN" ]; + DevicePolicy = "closed"; + DeviceAllow = [ "block-blkext rw" "block-sd rw" "char-nvme rw" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateNetwork = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + }; + }; + + systemd.timers.smart-selftest-collector = { + description = "Collect SMART self-test history periodically"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "3min"; + OnUnitActiveSec = "15min"; + AccuracySec = "1min"; + Unit = "smart-selftest-collector.service"; + }; + }; + + # Pool capacity and health, vdev errors, scrub history, dataset properties and + # snapshot retention. See zfsCollector above for why node_exporter's built-in zfs + # collector isn't enough on its own. + systemd.services.zfs-textfile-collector = lib.mkIf config.boot.zfs.enabled { + description = "Collect ZFS metrics for the Prometheus node exporter"; + serviceConfig = { + Type = "oneshot"; + ExecStart = lib.getExe zfsCollector; + User = "root"; + StateDirectory = "prometheus-node-exporter-textfile"; + StateDirectoryMode = "0755"; + UMask = "0022"; # node_exporter runs as a different user and must read these + # Listing every snapshot on a busy pool is the slow part; keep this well + # above the subprocess timeout inside the collector. + TimeoutStartSec = "180s"; + + # Hardening — this only needs to read ZFS state and write one file. + # CAP_SYS_ADMIN stays in the bounding set because some zfs ioctls are gated + # on it; everything else is dropped. + CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_OVERRIDE" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateNetwork = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ "AF_UNIX" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + }; + }; + + systemd.timers.zfs-textfile-collector = lib.mkIf config.boot.zfs.enabled { + description = "Collect ZFS metrics periodically"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "1min"; + OnUnitActiveSec = "1min"; + AccuracySec = "5s"; + Unit = "zfs-textfile-collector.service"; + }; + }; + + # The collector used to write zpool.prom. Nothing removes a stale textfile once its + # writer is gone, and node_exporter would happily keep serving those frozen metrics + # forever, so delete it on activation. + systemd.tmpfiles.rules = lib.mkIf config.boot.zfs.enabled [ + "r ${textfileDir}/zpool.prom" + ]; + + # Reachable from the container, which sits on br-lan0 with its own address. + # (Port 9273 is gone — that was telegraf/zpool_influxdb, replaced by the textfile + # collector above, which needs no listener of its own.) + networking.firewall.allowedTCPPorts = [ ports.node ports.smartctl ]; + + ########################################################################## + # Secrets + ########################################################################## + + sops.secrets.grafana-secret-key = { + sopsFile = ./resources/secrets/grafana.yaml; + key = "secret_key"; + }; + + ########################################################################## + # Container: Grafana / Prometheus / Loki + ########################################################################## + + containers.grafana = { + autoStart = true; + privateNetwork = true; + hostBridge = "br-lan0"; + localMacAddress = "02:00:00:00:00:06"; + + bindMounts."/run/secrets/grafana-secret_key" = { + hostPath = config.sops.secrets.grafana-secret-key.path; + isReadOnly = true; + }; + + config = { lib, pkgs, config, ... }: { + # Network + networking.enableIPv6 = false; + networking.interfaces.eth0.useDHCP = true; + networking.firewall.allowedTCPPorts = [ + 80 # Caddy -> Grafana + ports.loki # host's Alloy pushes here + ports.prometheus # handy for debugging scrape health directly + ]; + + # --------------------------------------------------------------- + # Loki + # --------------------------------------------------------------- + services.loki = { + enable = true; + configuration = { + server = { + http_listen_port = ports.loki; + # Explicit, since the host's Alloy needs to reach it to push logs. + http_listen_address = "0.0.0.0"; + }; + auth_enabled = false; + + common = { + path_prefix = "/var/lib/loki"; + storage.filesystem = { + chunks_directory = "/var/lib/loki/chunks"; + rules_directory = "/var/lib/loki/rules"; + }; + ring = { + instance_addr = "127.0.0.1"; + kvstore.store = "inmemory"; + }; + replication_factor = 1; + }; + + schema_config.configs = [{ + from = "2024-01-01"; + store = "tsdb"; + object_store = "filesystem"; + schema = "v13"; + index = { prefix = "index_"; period = "24h"; }; + }]; + }; + }; + + # --------------------------------------------------------------- + # Prometheus + # --------------------------------------------------------------- + services.prometheus = { + enable = true; + + # Without this Prometheus falls back to its built-in 1m scrape interval, + # which desynchronises from what Grafana assumes and breaks every + # rate()-based panel. See the comment on `scrapeInterval` at the top. + globalConfig = { + scrape_interval = scrapeInterval; + evaluation_interval = scrapeInterval; + scrape_timeout = "10s"; + }; + + # Drive health verdict. Kept as recording rules rather than inlined in + # dashboard panels so the logic is written once and reviewable on its + # own. services.prometheus.checkConfig runs promtool at build time, so + # a broken rule fails the build instead of the service. + ruleFiles = [ ./rules/smartctl.yml ]; + + scrapeConfigs = [ + { + job_name = "node"; + static_configs = [{ targets = [ "${hostName}:${toString ports.node}" ]; }]; + } + { + job_name = "smartctl"; + # Querying SMART is comparatively slow and the data barely moves, so + # this job opts out of the global 15s. Safe to do here because the + # smartctl dashboard uses no rate() queries at all. + scrape_interval = "60s"; + scrape_timeout = "30s"; + static_configs = [{ targets = [ "${hostName}:${toString ports.smartctl}" ]; }]; + } + ]; + }; + + # --------------------------------------------------------------- + # Grafana + # https://wiki.nixos.org/wiki/Grafana + # --------------------------------------------------------------- + services.grafana = { + enable = true; + settings.server = { http_addr = "0.0.0.0"; http_port = ports.grafana; }; + + provision = { + enable = true; + + # Mutable dashboard provider pulling from /etc/grafana-dashboards, so + # dashboards can be declared with environment.etc below. + dashboards.settings.providers = [{ + name = "my dashboards"; + disableDeletion = true; + options = { + path = "/etc/grafana-dashboards"; + foldersFromFilesStructure = true; + }; + }]; + + # Grafana cannot change the UID of an existing datasource in place. Its + # provisioner finds the datasource by name, then calls UpdateDataSource, + # which re-fetches using `id AND uid` — with a new uid that row doesn't + # match, so it fails with "data source not found" and Grafana refuses to + # start. Deleting first turns the upsert into a plain insert; the + # provisioner runs deletes before inserts for exactly this case, and + # suppresses the deletion event because the datasource is about to be + # re-created. Safe to leave in permanently: both datasources are fully + # declarative, and dashboards bind to them by UID, not by database ID. + datasources.settings.deleteDatasources = [ + { name = "Prometheus"; orgId = 1; } + { name = "Loki"; orgId = 1; } + ]; + + datasources.settings.datasources = [ + { + name = "Prometheus"; + type = "prometheus"; + uid = dsUid.prometheus; # fixed, so dashboards can reference it + url = "http://127.0.0.1:${toString ports.prometheus}"; + isDefault = true; + jsonData = { + # Tells Grafana how often the data is actually scraped. This is + # what $__rate_interval is derived from; leaving it unset makes + # Grafana guess 15s and mis-size every rate() window. + timeInterval = scrapeInterval; + httpMethod = "POST"; + }; + } + { + name = "Loki"; + type = "loki"; + uid = dsUid.loki; + url = "http://127.0.0.1:${toString ports.loki}"; + } + ]; + }; + }; + + # Grafana secret key + systemd.services.grafana.serviceConfig.LoadCredential = [ + "secret_key:/run/secrets/grafana-secret_key" + ]; + services.grafana.settings.security.secret_key = + "$__file{/run/credentials/grafana.service/secret_key}"; + + # --------------------------------------------------------------- + # Dashboards + # --------------------------------------------------------------- + environment.etc = lib.mapAttrs' (name: drv: + lib.nameValuePair "grafana-dashboards/${name}.json" { source = drv; } + ) dashboards; + + # --------------------------------------------------------------- + # Reverse proxy + # --------------------------------------------------------------- + services.caddy = { + enable = true; + virtualHosts.":80".extraConfig = '' + reverse_proxy localhost:${toString ports.grafana} + ''; + }; + + system.stateVersion = "26.05"; + }; + }; +} |
