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}