diff options
| author | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 23:22:24 -0500 |
|---|---|---|
| committer | Tim Keller <tjk@tjkeller.xyz> | 2026-08-27 23:22:24 -0500 |
| commit | fe770e9f386e091dbca7cf587cfe0131f10bd728 (patch) | |
| tree | a673fa706f1049c5fa43bb78fc3d970aebe28455 /modules/server.nix | |
| parent | 89f2c328fc1b113a9e9b862841e0c7d0391b3d09 (diff) | |
| download | telemetry-flake-fe770e9f386e091dbca7cf587cfe0131f10bd728.tar.xz telemetry-flake-fe770e9f386e091dbca7cf587cfe0131f10bd728.zip | |
add server.alerts: Alertmanager with email notifications
Turns the Alertmanager wiring sketched out in conversation into an option, and
ships the alerting rules rather than leaving them as something to copy out of a
chat log.
services.telemetry.server.alerts = {
enable = true;
to = [ "you@example.com" ];
from = "alerts@example.com";
smarthost = "smtp.fastmail.com:587";
username = "alerts@example.com";
passwordFile = config.sops.secrets.smtp-password.path;
};
passwordFile becomes smtp_auth_password_file, read at runtime, so the password
never enters the store. `settings` is merged over the generated configuration
for inhibit rules and severity routing. Alerts group by alertname and host, so
nine failing drives on one machine arrive as one mail.
rules/alerts.yml carries twelve alerts: drive failing, degraded, failed
self-test, overheating; pool not ONLINE, unrepairable data errors, over 85%
full, scrub or snapshots overdue; exporter down, collector stale, textfile parse
error. Most are one-liners because rules/smartctl.yml has already reduced drive
health to a single smartctl:health_code. ExporterDown earns its place by
covering the case where a machine vanishes and every other alert goes quiet.
Alertmanager is defined inside the existing services.prometheus block: Nix will
not let one attrset both define services.prometheus wholesale and add a path
beneath it. Same shape as the grafana settings fix in 89f2c32.
Verified by building both ways. Enabled: two promtool-checked rule files, an
amtool-checked Alertmanager config with the password file, and Prometheus
pointing at 127.0.0.1:9093. Disabled: no alertmanager unit, one rule file, no
alerting stanza. The README's rule-override and alerts.settings snippets were
also built rather than written from memory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKczN1f66kmLxNBLX37voQ
Diffstat (limited to 'modules/server.nix')
| -rw-r--r-- | modules/server.nix | 146 |
1 files changed, 145 insertions, 1 deletions
diff --git a/modules/server.nix b/modules/server.nix index d9449a8..c6094ef 100644 --- a/modules/server.nix +++ b/modules/server.nix @@ -250,6 +250,90 @@ in }; }; + alerts = { + enable = mkEnableOption '' + Alertmanager, wired to Prometheus and delivering by email. + + The alerting rules themselves are plain PromQL in rules/alerts.yml, checked by + promtool at build time like everything else here -- so a typo in an expression + fails `nixos-rebuild`, not at 3am + ''; + + defaultRules = mkOption { + type = types.bool; + default = true; + description = '' + Load the alerting rules shipped with this module: drive failing or degraded, + failed self-test, drive overheating, pool not ONLINE, unrepairable data + errors, pool over 85% full, scrub or snapshots overdue, exporter down, and a + collector that has stopped refreshing its file. + + Turn this off and point + {option}`services.telemetry.server.prometheus.ruleFiles` at your own copy to + change the thresholds. + ''; + }; + + to = mkOption { + type = types.listOf types.str; + example = [ "you@example.com" ]; + description = "Where notifications are sent."; + }; + + from = mkOption { + type = types.str; + example = "alerts@example.com"; + description = "Envelope sender. Many providers require this to be the account you authenticate as."; + }; + + smarthost = mkOption { + type = types.str; + example = "smtp.fastmail.com:587"; + description = "SMTP server and port to relay through."; + }; + + username = mkOption { + type = types.nullOr types.str; + default = null; + description = "SMTP username. Null sends unauthenticated, which only works for a local relay."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = lib.literalExpression "config.sops.secrets.smtp-password.path"; + description = '' + File containing the SMTP password. Passed to Alertmanager as + `smtp_auth_password_file` and read at runtime, so it never enters the Nix + store. Must be readable by the alertmanager user. + ''; + }; + + requireTLS = mkOption { + type = types.bool; + default = true; + description = "Require STARTTLS. Turn off only for a local relay on loopback."; + }; + + repeatInterval = mkOption { + type = types.str; + default = "24h"; + description = '' + How long before an alert that is still firing is re-sent. A drive does not + stop failing, so this is the knob that decides how often it nags you. + ''; + }; + + settings = mkOption { + type = types.attrs; + default = { }; + description = '' + Merged over the generated Alertmanager configuration, last wins. The escape + hatch for inhibit rules, extra receivers, or routing by severity. + ''; + }; + }; + proxy = { enable = mkOption { type = types.bool; @@ -287,6 +371,14 @@ in ''; } { + assertion = !cfg.alerts.enable || cfg.prometheus.enable; + message = '' + services.telemetry.server.alerts.enable requires prometheus.enable -- the + alerting rules are evaluated by Prometheus, which then pushes to + Alertmanager. + ''; + } + { assertion = !cfg.grafana.enable || secretKeyPath != null; message = '' Grafana needs a secret key. Either leave @@ -348,7 +440,8 @@ in scrape_timeout = "10s"; }; - inherit (cfg.prometheus) ruleFiles; + ruleFiles = cfg.prometheus.ruleFiles + ++ lib.optional (cfg.alerts.enable && cfg.alerts.defaultRules) ../rules/alerts.yml; scrapeConfigs = optionals (cfg.prometheus.nodeTargets != [ ]) [{ @@ -364,6 +457,54 @@ in static_configs = [{ targets = cfg.prometheus.smartctlTargets; }]; }] ++ cfg.prometheus.extraScrapeConfigs; + + # Alertmanager lives inside this block rather than in a `services.prometheus.*` + # definition of its own: Nix will not let one attrset both define + # `services.prometheus` wholesale and add a path underneath it. + alertmanagers = mkIf cfg.alerts.enable [{ + static_configs = [{ targets = [ "127.0.0.1:${toString ports.alertmanager}" ]; }]; + }]; + + alertmanager = mkIf cfg.alerts.enable { + enable = true; + port = ports.alertmanager; + listenAddress = cfg.listenAddress; + configuration = lib.recursiveUpdate + { + global = { + smtp_smarthost = cfg.alerts.smarthost; + smtp_from = cfg.alerts.from; + smtp_require_tls = cfg.alerts.requireTLS; + } + // lib.optionalAttrs (cfg.alerts.username != null) { + smtp_auth_username = cfg.alerts.username; + } + // lib.optionalAttrs (cfg.alerts.passwordFile != null) { + smtp_auth_password_file = toString cfg.alerts.passwordFile; + }; + + route = { + receiver = "email"; + # By alertname and host, so nine failing drives on one machine arrive as one + # mail rather than nine. + group_by = [ "alertname" "host" ]; + group_wait = "1m"; + group_interval = "10m"; + repeat_interval = cfg.alerts.repeatInterval; + }; + + receivers = [{ + name = "email"; + email_configs = map + (addr: { + to = addr; + send_resolved = true; + }) + cfg.alerts.to; + }]; + } + cfg.alerts.settings; + }; }; # -------------------------------------------------------------------------------- @@ -481,6 +622,9 @@ in networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall ( optionals cfg.loki.enable [ ports.loki ] ++ optionals cfg.prometheus.enable [ ports.prometheus ] + # The Alertmanager UI is how you silence an alert. Note anyone who can reach it + # can silence anything -- same trust model as the Prometheus port above. + ++ optionals cfg.alerts.enable [ ports.alertmanager ] ++ (if cfg.proxy.enable then [ 80 443 ] else optionals cfg.grafana.enable [ ports.grafana ]) ); }; |
