flake-update-20260505
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.nix-flake-updater;
12
13 instanceOpts =
14 { config, ... }:
15 {
16 options = {
17 enable = mkEnableOption "this flake updater instance";
18
19 repoPath = mkOption {
20 type = types.str;
21 example = "/home/user/nixos-config";
22 description = "Path to the git repository containing the flake";
23 };
24
25 flakePath = mkOption {
26 type = types.str;
27 default = config.repoPath;
28 example = "/home/user/nixos-config";
29 description = "Path to the flake (usually same as repoPath)";
30 };
31
32 gitRemote = mkOption {
33 type = types.str;
34 default = "origin";
35 description = "Git remote name to push to";
36 };
37
38 mainBranch = mkOption {
39 type = types.str;
40 default = "main";
41 description = "Main branch name (for auto-merge)";
42 };
43
44 branchPrefix = mkOption {
45 type = types.str;
46 default = "flake-update-";
47 description = "Prefix for update branches";
48 };
49
50 flakeInputs = mkOption {
51 type = types.listOf types.str;
52 default = [ ];
53 example = [
54 "chick-group"
55 "chapeau-rouge"
56 ];
57 description = "List of specific flake inputs to update (empty = all)";
58 };
59
60 autoMerge = mkOption {
61 type = types.bool;
62 default = false;
63 description = "If true, automatically merge to main branch on successful build";
64 };
65
66 inboxOrg = mkOption {
67 type = types.str;
68 default = "/home/${config.user}/desktop/org/inbox.org";
69 example = "/home/user/org/inbox.org";
70 description = "Path to org-mode inbox file for TODO entries on failure";
71 };
72
73 buildSystems = mkOption {
74 type = types.listOf types.str;
75 default = [ ];
76 example = [
77 "aomi"
78 "sakhalin"
79 ];
80 description = "List of NixOS systems to build for verification";
81 };
82
83 schedule = mkOption {
84 type = types.str;
85 default = "weekly";
86 example = "Mon *-*-* 02:00:00";
87 description = "Systemd timer schedule (OnCalendar format or 'weekly'/'daily')";
88 };
89
90 ntfyTopic = mkOption {
91 type = types.str;
92 default = "nix-updates";
93 description = "ntfy topic for notifications";
94 };
95
96 ntfyServer = mkOption {
97 type = types.str;
98 default = "https://ntfy.sh";
99 example = "http://ntfy.sbr.pm";
100 description = "ntfy server URL";
101 };
102
103 ntfyTokenFile = mkOption {
104 type = types.nullOr types.path;
105 default = null;
106 description = "Path to file containing ntfy authentication token (optional)";
107 };
108
109 dryRun = mkOption {
110 type = types.bool;
111 default = false;
112 description = "If true, don't push to remote (testing mode)";
113 };
114
115 user = mkOption {
116 type = types.str;
117 default = "root";
118 description = "User to run the update as";
119 };
120
121 randomizedDelaySec = mkOption {
122 type = types.int;
123 default = 3600;
124 description = "Random delay in seconds before starting (0-value)";
125 };
126
127 sshKeyFile = mkOption {
128 type = types.str;
129 default = "/home/${config.user}/.ssh/id_ed25519";
130 example = "/home/user/.ssh/id_passage";
131 description = "Path to the SSH private key for git push (must be authorized on the remote)";
132 };
133
134 autoFix = {
135 enable = mkEnableOption "AI-powered auto-fix on build failure";
136
137 command = mkOption {
138 type = types.str;
139 default = "pi";
140 description = "Agent command to invoke (must support -p for non-interactive mode)";
141 };
142
143 extraArgs = mkOption {
144 type = types.listOf types.str;
145 default = [
146 "--provider"
147 "google-vertex-claude"
148 "--no-session"
149 "--no-themes"
150 "--no-skills"
151 ];
152 description = "Extra arguments passed to the agent command (note: do not use --no-extensions if the provider is an extension)";
153 };
154
155 maxAttempts = mkOption {
156 type = types.int;
157 default = 3;
158 description = "Maximum agent invocations per failing host before giving up";
159 };
160
161 envFile = mkOption {
162 type = types.nullOr types.path;
163 default = null;
164 description = "Optional file to source before running the agent (for API keys, credentials)";
165 };
166
167 environment = mkOption {
168 type = types.attrsOf types.str;
169 default = { };
170 example = {
171 GOOGLE_CLOUD_PROJECT = "my-project";
172 GOOGLE_CLOUD_LOCATION = "global";
173 };
174 description = "Environment variables to set when running the agent";
175 };
176 };
177 };
178 };
179
180 mkUpdateScript =
181 name: instanceCfg:
182 pkgs.writeShellScript "nix-flake-update-${name}" ''
183 export REPO_PATH="${instanceCfg.repoPath}"
184 export FLAKE_PATH="${instanceCfg.flakePath}"
185 export GIT_REMOTE="${instanceCfg.gitRemote}"
186 export MAIN_BRANCH="${instanceCfg.mainBranch}"
187 export BRANCH_PREFIX="${instanceCfg.branchPrefix}"
188 export NTFY_TOPIC="${instanceCfg.ntfyTopic}"
189 export NTFY_SERVER="${instanceCfg.ntfyServer}"
190 export BUILD_SYSTEMS="${toString instanceCfg.buildSystems}"
191 export DRY_RUN="${toString instanceCfg.dryRun}"
192 export FLAKE_INPUTS="${toString instanceCfg.flakeInputs}"
193 export AUTO_MERGE="${toString instanceCfg.autoMerge}"
194 export INBOX_ORG="${instanceCfg.inboxOrg}"
195 ${optionalString (
196 instanceCfg.ntfyTokenFile != null
197 ) ''export NTFY_TOKEN_FILE="${instanceCfg.ntfyTokenFile}"''}
198 export AUTO_FIX="${toString instanceCfg.autoFix.enable}"
199 export AUTO_FIX_COMMAND="${instanceCfg.autoFix.command}"
200 export AUTO_FIX_EXTRA_ARGS="${concatStringsSep " " instanceCfg.autoFix.extraArgs}"
201 export AUTO_FIX_MAX_ATTEMPTS="${toString instanceCfg.autoFix.maxAttempts}"
202 ${optionalString (
203 instanceCfg.autoFix.envFile != null
204 ) ''export AUTO_FIX_ENV_FILE="${instanceCfg.autoFix.envFile}"''}
205 ${concatStringsSep "\n" (
206 mapAttrsToList (k: v: "export ${k}=\"${v}\"") instanceCfg.autoFix.environment
207 )}
208
209 # Execute the packaged update script (already has tools in PATH)
210 exec ${pkgs.nix-flake-update}/bin/nix-flake-update
211 '';
212
213 mkService =
214 name: instanceCfg:
215 nameValuePair "nix-flake-updater-${name}" {
216 description = "Automated Nix flake.lock updater (${name})";
217
218 serviceConfig = {
219 Type = "oneshot";
220 User = instanceCfg.user;
221 ExecStart = "${mkUpdateScript name instanceCfg}";
222 Environment = ''"GIT_SSH_COMMAND=ssh -F /dev/null -o IdentitiesOnly=yes -i ${instanceCfg.sshKeyFile} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/home/${instanceCfg.user}/.ssh/known_hosts"'';
223
224 # Don't fail if update fails (e.g., no changes, build failures)
225 SuccessExitStatus = "0 1";
226
227 # Security hardening
228 PrivateTmp = true;
229 ProtectSystem = "strict";
230 ProtectHome = "read-only";
231 ReadWritePaths = [
232 instanceCfg.repoPath
233 "/var/log/nix-flake-updater"
234 # Worktree location (script creates worktrees in ~/tmp)
235 "/home/${instanceCfg.user}/tmp"
236 # Nix cache for flake fetcher
237 "/home/${instanceCfg.user}/.cache/nix"
238 # Org inbox for TODOs
239 (dirOf instanceCfg.inboxOrg)
240 # Pi agent session/config directory (needed for auto-fix)
241 "/home/${instanceCfg.user}/.pi"
242 ];
243 NoNewPrivileges = true;
244
245 # Logging
246 StandardOutput = "journal";
247 StandardError = "journal";
248 SyslogIdentifier = "nix-flake-updater-${name}";
249 };
250 };
251
252 mkTimer =
253 name: instanceCfg:
254 nameValuePair "nix-flake-updater-${name}" {
255 description = "Timer for automated Nix flake.lock updates (${name})";
256 wantedBy = [ "timers.target" ];
257
258 timerConfig = {
259 OnCalendar = instanceCfg.schedule;
260 RandomizedDelaySec = instanceCfg.randomizedDelaySec;
261 Persistent = true;
262 };
263 };
264
265in
266{
267 options.services.nix-flake-updater = mkOption {
268 type = types.attrsOf (types.submodule instanceOpts);
269 default = { };
270 description = "Automated Nix flake.lock updater instances";
271 };
272
273 config = mkIf (cfg != { }) (
274 let
275 # Collect all unique users from enabled instances
276 users = unique (
277 mapAttrsToList (_: instanceCfg: instanceCfg.user) (filterAttrs (_: v: v.enable) cfg)
278 );
279 in
280 {
281 systemd.services = listToAttrs (
282 mapAttrsToList (name: instanceCfg: mkService name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
283 );
284
285 systemd.timers = listToAttrs (
286 mapAttrsToList (name: instanceCfg: mkTimer name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
287 );
288
289 # Ensure log directory exists (shared by all instances)
290 # Create with permissions for all users that need access
291 systemd.tmpfiles.rules = [
292 "d /var/log/nix-flake-updater 0775 root users -"
293 ]
294 ++ map (user: "Z /var/log/nix-flake-updater - ${user} - -") users;
295 }
296 );
297}