feat(services): report the age of the nixpkgs lock

A rebuild against an unchanged lock installs nothing new, so a machine
built daily can still run months-old packages. A user timer reads the
locked lastModified of the nixpkgs input and notifies once it passes
seven days, offering a terminal that runs the update.
This commit is contained in:
nevaforget committed 2026-09-10 13:32:14 +02:00
1 parent b1ed5d087b
commit c8d9a00a8d
4 files changed
+157 -1

No files matched your search

+6
View File
@@ -258,6 +258,12 @@ in
networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ];
environment.systemPackages = with pkgs; [
# XDG plumbing, before anything that calls into it. xdg-terminal-exec
# resolves a caller to whichever TerminalEmulator entry the XDG data
# hierarchy offers — footclient here, which reuses the server niri starts
# — so nothing downstream has to name an emulator.
xdg-terminal-exec
# Own programs
moonarchPkgs.sweet-cursors
moonarchPkgs.moonarch-scripts
+105 -1
View File
@@ -1,8 +1,79 @@
# ABOUTME: systemd system and user services for the desktop session.
# ABOUTME: Rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths.
# ABOUTME: Mostly rebuilt from moonarch/defaults/etc/systemd/, whose units use FHS paths.
{ pkgs, lib, moonarchPkgs, ... }:
let
# Days after which the nixpkgs input counts as stale. nixpkgs carries no
# patch backports, it bumps versions, so an old lock means known holes stay
# open until the next update — the age of the lock is the exposure.
maxLockAgeDays = 7;
# Reports the age of the nixpkgs input to the session's notification daemon.
#
# The age comes from the lock rather than the file mtime: a fresh clone or a
# branch switch rewrites the mtime without changing a single package version.
#
# The flake is found through /etc/nixos/flake.nix, which README documents as a
# symlink into the account's checkout. A machine built straight from the
# remote reference has no checkout and therefore no lock to read, which is why
# a missing link is not an error.
flakeAgeCheck = pkgs.writeShellApplication {
name = "moonix-flake-age";
runtimeInputs = with pkgs; [ bash coreutils jq libnotify xdg-terminal-exec ];
text = ''
link=/etc/nixos/flake.nix
[ -L "$link" ] || exit 0
flake=$(dirname "$(readlink -f "$link")")
lock="$flake/flake.lock"
[ -r "$lock" ] || exit 0
# root.inputs.nixpkgs names the node rather than being it: the entry is
# called nixpkgs_2 as soon as a dependency locks a nixpkgs of its own.
# Reading .nodes.nixpkgs directly would report a transitive input's date.
locked=$(jq -r '.nodes[.nodes.root.inputs.nixpkgs].locked.lastModified' "$lock")
if [ -z "$locked" ] || [ "$locked" = null ]; then
echo "no nixpkgs lastModified in $lock" >&2
exit 1
fi
days=$(( ( $(date +%s) - locked ) / 86400 ))
[ "$days" -ge ${toString maxLockAgeDays} ] || exit 0
# The path is resolved rather than written out: `nix flake update` acts on
# the working directory, so without the right one it updates nothing or
# the wrong flake. printf because the body carries newlines.
#
# Three lines exactly: NotifToast.qml caps the body at maximumLineCount 3
# and elides the rest, so a fourth line would drop a command.
body=$(printf 'cd %s\nnix flake update\nsudo nixos-rebuild switch' "$flake")
# Critical rather than normal: a stale lock does not pass, it stays true
# until a rebuild, and the toast is exempt from the auto-expire timer.
#
# -A implies --wait, so this blocks while the toast stands and prints the
# chosen action; a dismissed toast leaves it empty. Nothing has to bound
# that wait: while the process lives the notification is still on screen,
# and a second one a day later would say the same thing.
choice=$(notify-send -a moonix -u critical \
-A update="Update now" \
"nixpkgs is $days days old" "$body" || true)
[ "$choice" = update ] || exit 0
# A terminal rather than a silent rebuild: nixpkgs is unstable, so the
# build can fail, and sudo needs someone to answer it. xdg-terminal-exec
# instead of a named emulator so switching terminals is one entry in
# xdg-terminals.list. bash -lc for the PATH — neither the system nix nor
# the sudo wrapper is in this script's closure, and pinning nix here
# would shadow nix.package.
exec xdg-terminal-exec bash -lc \
"cd \"$flake\" && nix flake update && sudo nixos-rebuild switch; \
read -r -p '[enter to close] ' _"
'';
};
in
{
# --- Audio ---
services.pipewire = {
@@ -159,6 +230,39 @@
};
};
# --- Flake age ---
#
# A user unit rather than a system one: the notification goes to the session's
# daemon, and the checkout it reads belongs to the account.
systemd.user.services.moonix-flake-age = {
description = "Warn when the nixpkgs input has gone stale";
serviceConfig = {
Type = "oneshot";
ExecStart = lib.getExe flakeAgeCheck;
# ProtectHome is left off deliberately: the lock sits in the account's
# checkout. The rest costs nothing for a script that only reads a file.
NoNewPrivileges = true;
PrivateTmp = true;
RestrictNamespaces = true;
RestrictRealtime = true;
LockPersonality = true;
};
};
systemd.user.timers.moonix-flake-age = {
description = "Daily nixpkgs staleness check";
wantedBy = [ "timers.target" ];
# Relative to the user manager, not the calendar: a desktop that is off at
# midnight would otherwise report on a session nobody is looking at. The
# delay after login gives the notification daemon in the bar time to claim
# the D-Bus name.
timerConfig = {
OnStartupSec = "5min";
OnUnitActiveSec = "1d";
};
};
# The polkit agent is not a service here: niri spawns it via
# spawn-at-startup in defaults/xdg/niri/config.kdl. Only the FHS path it
# references has to exist — see modules/desktop.nix.