main
  1{
  2  config,
  3  lib,
  4  pkgs,
  5  ...
  6}:
  7
  8with lib;
  9let
 10  cfg = config.services.harmonia-cache;
 11in
 12{
 13  options = {
 14    services.harmonia-cache = {
 15      enable = mkEnableOption "Harmonia binary cache server";
 16
 17      signKeyPath = mkOption {
 18        type = types.str;
 19        description = ''
 20          Path to the signing key for the binary cache.
 21          Typically provided by agenix secret.
 22        '';
 23      };
 24
 25      port = mkOption {
 26        type = types.port;
 27        default = 5000;
 28        description = ''
 29          Port to bind the Harmonia server to.
 30        '';
 31      };
 32
 33      workers = mkOption {
 34        type = types.int;
 35        default = 4;
 36        description = ''
 37          Number of worker threads for Harmonia.
 38        '';
 39      };
 40
 41      maxConnectionRate = mkOption {
 42        type = types.int;
 43        default = 256;
 44        description = ''
 45          Maximum connection rate for Harmonia.
 46        '';
 47      };
 48
 49      priority = mkOption {
 50        type = types.int;
 51        default = 30;
 52        description = ''
 53          Priority of this cache relative to other substituters.
 54          Lower values = higher priority.
 55        '';
 56      };
 57
 58      builder = {
 59        enable = mkEnableOption "Nightly cache pre-population builds";
 60
 61        systems = mkOption {
 62          type = types.listOf types.str;
 63          default = [ ];
 64          example = [
 65            "aomi"
 66            "kyushu"
 67          ];
 68          description = ''
 69            List of NixOS system configurations to build nightly.
 70            These should be system names from the flake's nixosConfigurations.
 71          '';
 72        };
 73
 74        flakePath = mkOption {
 75          type = types.str;
 76          default = "/home/vincent/src/home";
 77          description = ''
 78            Path to the flake repository containing system configurations.
 79          '';
 80        };
 81
 82        schedule = mkOption {
 83          type = types.str;
 84          default = "daily";
 85          example = "02:00";
 86          description = ''
 87            When to run the cache builder. Can be a systemd calendar expression
 88            or one of: hourly, daily, weekly, monthly.
 89          '';
 90        };
 91
 92        notification = {
 93          enable = mkOption {
 94            type = types.bool;
 95            default = false;
 96            description = "Enable ntfy notifications for build status";
 97          };
 98
 99          ntfyUrl = mkOption {
100            type = types.str;
101            default = "https://ntfy.sbr.pm";
102            description = "ntfy server URL";
103          };
104
105          topic = mkOption {
106            type = types.str;
107            default = "builds";
108            description = "ntfy topic to publish to";
109          };
110
111          tokenFile = mkOption {
112            type = types.nullOr types.path;
113            default = null;
114            description = "Path to file containing ntfy auth token";
115          };
116        };
117      };
118    };
119  };
120
121  config = mkIf cfg.enable (mkMerge [
122    # Harmonia cache server
123    {
124      services.harmonia = {
125        enable = true;
126        signKeyPaths = [ cfg.signKeyPath ];
127        settings = {
128          bind = "[::]:${toString cfg.port}";
129          inherit (cfg) workers;
130          max_connection_rate = cfg.maxConnectionRate;
131          inherit (cfg) priority;
132        };
133      };
134
135      networking.firewall.allowedTCPPorts = [ cfg.port ];
136
137      # Ensure the signing key file exists and has correct permissions
138      assertions = [
139        {
140          assertion = cfg.signKeyPath != "";
141          message = "services.harmonia-cache.signKeyPath must be set";
142        }
143      ];
144    }
145
146    # Cache pre-population builder
147    (mkIf cfg.builder.enable {
148      assertions = [
149        {
150          assertion = cfg.builder.systems != [ ];
151          message = "services.harmonia-cache.builder.systems must not be empty when builder is enabled";
152        }
153      ];
154
155      systemd.services.harmonia-cache-builder = {
156        description = "Build NixOS systems for cache pre-population";
157        wants = [ "network-online.target" ];
158        after = [
159          "network-online.target"
160          "harmonia.service"
161        ];
162
163        serviceConfig = {
164          Type = "oneshot";
165          User = "vincent";
166          # Set a reasonable timeout (2 hours)
167          TimeoutStartSec = "2h";
168        };
169
170        path = with pkgs; [
171          openssh
172          git
173          nix
174          nixos-rebuild
175        ];
176
177        script =
178          let
179            notifyStart = optionalString cfg.builder.notification.enable ''
180              ${pkgs.curl}/bin/curl -X POST \
181                ${
182                  optionalString (cfg.builder.notification.tokenFile != null)
183                    ''-H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.builder.notification.tokenFile})"''
184                } \
185                -H "Title: Cache Builder Starting (${config.networking.hostName})" \
186                -d "Building ${toString (length cfg.builder.systems)} system(s) for cache pre-population" \
187                ${cfg.builder.notification.ntfyUrl}/${cfg.builder.notification.topic} || true
188            '';
189
190            notifySuccess = optionalString cfg.builder.notification.enable ''
191              ${pkgs.curl}/bin/curl -X POST \
192                ${
193                  optionalString (cfg.builder.notification.tokenFile != null)
194                    ''-H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.builder.notification.tokenFile})"''
195                } \
196                -H "Title: Cache Builder Complete (${config.networking.hostName})" \
197                -H "Tags: white_check_mark" \
198                -d "Successfully built ${toString (length cfg.builder.systems)} system(s)" \
199                ${cfg.builder.notification.ntfyUrl}/${cfg.builder.notification.topic} || true
200            '';
201
202            notifyFailure = optionalString cfg.builder.notification.enable ''
203              ${pkgs.curl}/bin/curl -X POST \
204                ${
205                  optionalString (cfg.builder.notification.tokenFile != null)
206                    ''-H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${cfg.builder.notification.tokenFile})"''
207                } \
208                -H "Title: Cache Builder Failed (${config.networking.hostName})" \
209                -H "Tags: x,warning" \
210                -H "Priority: high" \
211                -d "Failed to build systems. Check logs: journalctl -u harmonia-cache-builder.service" \
212                ${cfg.builder.notification.ntfyUrl}/${cfg.builder.notification.topic} || true
213            '';
214
215            buildSystems = concatMapStringsSep "\n" (system: ''
216              echo "Building system: ${system}"
217              ${pkgs.nixos-rebuild}/bin/nixos-rebuild build --flake ${cfg.builder.flakePath}#${system} || {
218                echo "Failed to build ${system}"
219                ${notifyFailure}
220                exit 1
221              }
222            '') cfg.builder.systems;
223          in
224          ''
225            set -euo pipefail
226
227            ${notifyStart}
228
229            cd ${cfg.builder.flakePath}
230
231            # Update flake inputs
232            echo "Updating flake inputs..."
233            ${pkgs.git}/bin/git pull || echo "Warning: Failed to pull latest changes"
234
235            ${buildSystems}
236
237            echo "All systems built successfully!"
238            ${notifySuccess}
239          '';
240      };
241
242      systemd.timers.harmonia-cache-builder = {
243        description = "Timer for harmonia cache pre-population";
244        wantedBy = [ "timers.target" ];
245
246        timerConfig = {
247          OnCalendar = cfg.builder.schedule;
248          Persistent = true;
249          RandomizedDelaySec = "30m";
250        };
251      };
252    })
253  ]);
254}