auto-update-daily-20260202
  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    };
128
129  mkUpdateScript =
130    name: instanceCfg:
131    pkgs.writeShellScript "nix-flake-update-${name}" ''
132      export REPO_PATH="${instanceCfg.repoPath}"
133      export FLAKE_PATH="${instanceCfg.flakePath}"
134      export GIT_REMOTE="${instanceCfg.gitRemote}"
135      export MAIN_BRANCH="${instanceCfg.mainBranch}"
136      export BRANCH_PREFIX="${instanceCfg.branchPrefix}"
137      export NTFY_TOPIC="${instanceCfg.ntfyTopic}"
138      export NTFY_SERVER="${instanceCfg.ntfyServer}"
139      export BUILD_SYSTEMS="${toString instanceCfg.buildSystems}"
140      export DRY_RUN="${toString instanceCfg.dryRun}"
141      export FLAKE_INPUTS="${toString instanceCfg.flakeInputs}"
142      export AUTO_MERGE="${toString instanceCfg.autoMerge}"
143      export INBOX_ORG="${instanceCfg.inboxOrg}"
144      ${optionalString (
145        instanceCfg.ntfyTokenFile != null
146      ) ''export NTFY_TOKEN_FILE="${instanceCfg.ntfyTokenFile}"''}
147
148      # Execute the packaged update script (already has tools in PATH)
149      exec ${pkgs.nix-flake-update}/bin/nix-flake-update
150    '';
151
152  mkService =
153    name: instanceCfg:
154    nameValuePair "nix-flake-updater-${name}" {
155      description = "Automated Nix flake.lock updater (${name})";
156
157      serviceConfig = {
158        Type = "oneshot";
159        User = instanceCfg.user;
160        ExecStart = "${mkUpdateScript name instanceCfg}";
161        Environment = ''"GIT_SSH_COMMAND=ssh -o ControlMaster=no"'';
162
163        # Don't fail if update fails (e.g., no changes, build failures)
164        SuccessExitStatus = "0 1";
165
166        # Security hardening
167        PrivateTmp = true;
168        ProtectSystem = "strict";
169        ProtectHome = "read-only";
170        ReadWritePaths = [
171          instanceCfg.repoPath
172          "/var/log/nix-flake-updater"
173          # Worktree location (script creates worktrees in ~/tmp)
174          "/home/${instanceCfg.user}/tmp"
175          # Nix cache for flake fetcher
176          "/home/${instanceCfg.user}/.cache/nix"
177          # Org inbox for TODOs
178          (dirOf instanceCfg.inboxOrg)
179        ];
180        NoNewPrivileges = true;
181
182        # Logging
183        StandardOutput = "journal";
184        StandardError = "journal";
185        SyslogIdentifier = "nix-flake-updater-${name}";
186      };
187    };
188
189  mkTimer =
190    name: instanceCfg:
191    nameValuePair "nix-flake-updater-${name}" {
192      description = "Timer for automated Nix flake.lock updates (${name})";
193      wantedBy = [ "timers.target" ];
194
195      timerConfig = {
196        OnCalendar = instanceCfg.schedule;
197        RandomizedDelaySec = instanceCfg.randomizedDelaySec;
198        Persistent = true;
199      };
200    };
201
202in
203{
204  options.services.nix-flake-updater = mkOption {
205    type = types.attrsOf (types.submodule instanceOpts);
206    default = { };
207    description = "Automated Nix flake.lock updater instances";
208  };
209
210  config = mkIf (cfg != { }) (
211    let
212      # Collect all unique users from enabled instances
213      users = unique (
214        mapAttrsToList (_: instanceCfg: instanceCfg.user) (filterAttrs (_: v: v.enable) cfg)
215      );
216    in
217    {
218      systemd.services = listToAttrs (
219        mapAttrsToList (name: instanceCfg: mkService name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
220      );
221
222      systemd.timers = listToAttrs (
223        mapAttrsToList (name: instanceCfg: mkTimer name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
224      );
225
226      # Ensure log directory exists (shared by all instances)
227      # Create with permissions for all users that need access
228      systemd.tmpfiles.rules = [
229        "d /var/log/nix-flake-updater 0775 root users -"
230      ]
231      ++ map (user: "Z /var/log/nix-flake-updater - ${user} - -") users;
232    }
233  );
234}