diff --git a/modules/module-list.nix b/modules/module-list.nix
index a23edc0f..1be337d0 100644
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -50,6 +50,7 @@
./services/dnsmasq.nix
./services/emacs.nix
./services/gitlab-runner.nix
+ ./services/hercules-ci-agent
./services/karabiner-elements
./services/khd
./services/kwm
diff --git a/modules/services/hercules-ci-agent/common.nix b/modules/services/hercules-ci-agent/common.nix
new file mode 100644
index 00000000..8b56d5a7
--- /dev/null
+++ b/modules/services/hercules-ci-agent/common.nix
@@ -0,0 +1,134 @@
+/*
+
+ This file is for options that NixOS and nix-darwin have in common.
+
+ Platform-specific code is in the respective default.nix files.
+
+*/
+
+{ config, lib, options, pkgs, ... }:
+let
+ inherit (lib)
+ filterAttrs
+ literalExpression
+ mkIf
+ mkOption
+ mkRemovedOptionModule
+ mkRenamedOptionModule
+ types
+ ;
+ literalMD = lib.literalMD or (x: lib.literalDocBook "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+ mdDoc = lib.mdDoc or (x: "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+
+ cfg = config.services.hercules-ci-agent;
+
+ inherit (import ./settings.nix { inherit pkgs lib; }) format settingsModule;
+
+ # TODO (roberth, >=2022) remove
+ checkNix =
+ if !cfg.checkNix
+ then ""
+ else if lib.versionAtLeast config.nix.package.version "2.3.10"
+ then ""
+ else
+ pkgs.stdenv.mkDerivation {
+ name = "hercules-ci-check-system-nix-src";
+ inherit (config.nix.package) src patches;
+ dontConfigure = true;
+ buildPhase = ''
+ echo "Checking in-memory pathInfoCache expiry"
+ if ! grep 'PathInfoCacheValue' src/libstore/store-api.hh >/dev/null; then
+ cat 1>&2 <.
+ '';
+ type = types.submoduleWith { modules = [ settingsModule ]; };
+ };
+
+ /*
+ Internal and/or computed values.
+
+ These are written as options instead of let binding to allow sharing with
+ default.nix on both NixOS and nix-darwin.
+ */
+ tomlFile = mkOption {
+ type = types.path;
+ internal = true;
+ defaultText = literalMD "generated `hercules-ci-agent.toml`";
+ description = mdDoc ''
+ The fully assembled config file.
+ '';
+ };
+ };
+
+ config = mkIf cfg.enable {
+ nix.extraOptions = lib.addContextFrom checkNix ''
+ # A store path that was missing at first may well have finished building,
+ # even shortly after the previous lookup. This *also* applies to the daemon.
+ narinfo-cache-negative-ttl = 0
+ '';
+ services.hercules-ci-agent = {
+ tomlFile =
+ format.generate "hercules-ci-agent.toml" cfg.settings;
+ settings.config._module.args = {
+ packageOption = options.services.hercules-ci-agent.package;
+ inherit pkgs;
+ };
+ };
+ };
+}
diff --git a/modules/services/hercules-ci-agent/default.nix b/modules/services/hercules-ci-agent/default.nix
new file mode 100644
index 00000000..d9fbf37b
--- /dev/null
+++ b/modules/services/hercules-ci-agent/default.nix
@@ -0,0 +1,81 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+ cfg = config.services.hercules-ci-agent;
+ user = config.users.users._hercules-ci-agent;
+in
+{
+ imports = [ ./common.nix ];
+
+ meta.maintainers = [
+ lib.maintainers.roberth or "roberth"
+ ];
+
+ options.services.hercules-ci-agent = {
+
+ logFile = mkOption {
+ type = types.path;
+ default = "/var/log/hercules-ci-agent.log";
+ description = "Stdout and sterr of hercules-ci-agent process.";
+ };
+ };
+
+ config = mkIf cfg.enable {
+ launchd.daemons.hercules-ci-agent = {
+ script = "exec ${cfg.package}/bin/hercules-ci-agent --config ${cfg.tomlFile}";
+
+ path = [ config.nix.package ];
+ environment = {
+ NIX_SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
+ };
+
+ serviceConfig.KeepAlive = true;
+ serviceConfig.RunAtLoad = true;
+ serviceConfig.StandardErrorPath = cfg.logFile;
+ serviceConfig.StandardOutPath = cfg.logFile;
+ serviceConfig.GroupName = "_hercules-ci-agent";
+ serviceConfig.UserName = "_hercules-ci-agent";
+ serviceConfig.WorkingDirectory = user.home;
+ serviceConfig.WatchPaths = [
+ cfg.settings.staticSecretsDirectory
+ ];
+ };
+
+ system.activationScripts.preActivation.text = ''
+ touch '${cfg.logFile}'
+ chown ${toString user.uid}:${toString user.gid} '${cfg.logFile}'
+ '';
+
+ # Trusted user allows simplified configuration and better performance
+ # when operating in a cluster.
+ nix.settings.trusted-users = [ "_hercules-ci-agent" ];
+ services.hercules-ci-agent.settings.nixUserIsTrusted = true;
+
+ users.knownGroups = [ "hercules-ci-agent" "_hercules-ci-agent" ];
+ users.knownUsers = [ "hercules-ci-agent" "_hercules-ci-agent" ];
+
+ users.users._hercules-ci-agent = {
+ uid = mkDefault 399;
+ gid = mkDefault config.users.groups._hercules-ci-agent.gid;
+ home = mkDefault cfg.settings.baseDirectory;
+ name = "_hercules-ci-agent";
+ createHome = true;
+ shell = "/bin/bash";
+ description = "System user for the Hercules CI Agent";
+ };
+ users.groups._hercules-ci-agent = {
+ gid = mkDefault 32001;
+ name = "_hercules-ci-agent";
+ description = "System group for the Hercules CI Agent";
+ };
+
+ services.hercules-ci-agent.settings.labels = {
+ darwin.label = config.system.darwinLabel;
+ darwin.revision = config.system.darwinRevision;
+ darwin.version = config.system.darwinVersion;
+ darwin.nix.daemon = config.nix.useDaemon;
+ darwin.nix.sandbox = config.nix.settings.sandbox;
+ };
+ };
+}
diff --git a/modules/services/hercules-ci-agent/settings.nix b/modules/services/hercules-ci-agent/settings.nix
new file mode 100644
index 00000000..023d720f
--- /dev/null
+++ b/modules/services/hercules-ci-agent/settings.nix
@@ -0,0 +1,152 @@
+# Not a module
+{ pkgs, lib }:
+let
+ inherit (lib)
+ types
+ literalExpression
+ mkOption
+ ;
+ literalMD = lib.literalMD or (x: lib.literalDocBook "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+ mdDoc = lib.mdDoc or (x: "Documentation not rendered. Please upgrade to a newer NixOS with markdown support.");
+
+ format = pkgs.formats.toml { };
+
+ settingsModule = { config, packageOption, pkgs, ... }: {
+ freeformType = format.type;
+ options = {
+ apiBaseUrl = mkOption {
+ description = mdDoc ''
+ API base URL that the agent will connect to.
+
+ When using Hercules CI Enterprise, set this to the URL where your
+ Hercules CI server is reachable.
+ '';
+ type = types.str;
+ default = "https://hercules-ci.com";
+ };
+ baseDirectory = mkOption {
+ type = types.path;
+ default = "/var/lib/hercules-ci-agent";
+ description = mdDoc ''
+ State directory (secrets, work directory, etc) for agent
+ '';
+ };
+ concurrentTasks = mkOption {
+ description = mdDoc ''
+ Number of tasks to perform simultaneously.
+
+ A task is a single derivation build, an evaluation or an effect run.
+ At minimum, you need 2 concurrent tasks for `x86_64-linux`
+ in your cluster, to allow for import from derivation.
+
+ `concurrentTasks` can be around the CPU core count or lower if memory is
+ the bottleneck.
+
+ The optimal value depends on the resource consumption characteristics of your workload,
+ including memory usage and in-task parallelism. This is typically determined empirically.
+
+ When scaling, it is generally better to have a double-size machine than two machines,
+ because each split of resources causes inefficiencies; particularly with regards
+ to build latency because of extra downloads.
+ '';
+ type = types.either types.ints.positive (types.enum [ "auto" ]);
+ default = "auto";
+ };
+ labels = mkOption {
+ description = mdDoc ''
+ A key-value map of user data.
+
+ This data will be available to organization members in the dashboard and API.
+
+ The values can be of any TOML type that corresponds to a JSON type, but arrays
+ can not contain tables/objects due to limitations of the TOML library. Values
+ involving arrays of non-primitive types may not be representable currently.
+ '';
+ type = format.type;
+ defaultText = literalExpression ''
+ {
+ agent.source = "..."; # One of "nixpkgs", "flake", "override"
+ lib.version = "...";
+ pkgs.version = "...";
+ }
+ '';
+ };
+ workDirectory = mkOption {
+ description = mdDoc ''
+ The directory in which temporary subdirectories are created for task state. This includes sources for Nix evaluation.
+ '';
+ type = types.path;
+ default = config.baseDirectory + "/work";
+ defaultText = literalExpression ''baseDirectory + "/work"'';
+ };
+ staticSecretsDirectory = mkOption {
+ description = mdDoc ''
+ This is the default directory to look for statically configured secrets like `cluster-join-token.key`.
+
+ See also `clusterJoinTokenPath` and `binaryCachesPath` for fine-grained configuration.
+ '';
+ type = types.path;
+ default = config.baseDirectory + "/secrets";
+ defaultText = literalExpression ''baseDirectory + "/secrets"'';
+ };
+ clusterJoinTokenPath = mkOption {
+ description = mdDoc ''
+ Location of the cluster-join-token.key file.
+
+ You can retrieve the contents of the file when creating a new agent via
+ .
+
+ As this value is confidential, it should not be in the store, but
+ installed using other means, such as agenix, NixOps
+ `deployment.keys`, or manual installation.
+
+ The contents of the file are used for authentication between the agent and the API.
+ '';
+ type = types.path;
+ default = config.staticSecretsDirectory + "/cluster-join-token.key";
+ defaultText = literalExpression ''staticSecretsDirectory + "/cluster-join-token.key"'';
+ };
+ binaryCachesPath = mkOption {
+ description = mdDoc ''
+ Path to a JSON file containing binary cache secret keys.
+
+ As these values are confidential, they should not be in the store, but
+ copied over using other means, such as agenix, NixOps
+ `deployment.keys`, or manual installation.
+
+ The format is described on .
+ '';
+ type = types.path;
+ default = config.staticSecretsDirectory + "/binary-caches.json";
+ defaultText = literalExpression ''staticSecretsDirectory + "/binary-caches.json"'';
+ };
+ secretsJsonPath = mkOption {
+ description = mdDoc ''
+ Path to a JSON file containing secrets for effects.
+
+ As these values are confidential, they should not be in the store, but
+ copied over using other means, such as agenix, NixOps
+ `deployment.keys`, or manual installation.
+
+ The format is described on .
+ '';
+ type = types.path;
+ default = config.staticSecretsDirectory + "/secrets.json";
+ defaultText = literalExpression ''staticSecretsDirectory + "/secrets.json"'';
+ };
+ };
+ config = {
+ labels = {
+ agent.source =
+ if packageOption.highestPrio == (lib.modules.mkOptionDefault { }).priority
+ then "nixpkgs"
+ else lib.mkOptionDefault "override";
+ pkgs.version = pkgs.lib.version;
+ lib.version = lib.version;
+ };
+ };
+ };
+in
+{
+ inherit format settingsModule;
+}