From 06f5dab0657f0a51c8a220bdb2b6089ce68b2e96 Mon Sep 17 00:00:00 2001 From: Vincent Haupert Date: Wed, 28 Feb 2024 09:40:25 +0100 Subject: [PATCH] github-runners: adapt to NixOS module While #859 added basic support for configuring GitHub runners through nix-darwin, it did not yet support all of the options the NixOS module offers. I am aware that this is a rather big overhaul. I think, however, that it's worth it: - Copies the `options.nix` from the [NixOS module] with only minor adaptations. This should help to keep track of any changes to it. - Respect the `workDir` config option. So far, the implementation didn't even read the value of the option. - Allow configuring a custom user and group. If both are `null`, nix-darwin manages the `_github-runner` user shared among all instances. Take care of creating your own users if that's not what you want. - Also creates the necessary directories for state, logs and the working directory (unless `workDir != null`). It uses the following locations: * state: `/var/lib/github-runners/${name}` * logs: `/var/log/github-runners/${name}` * work: The value of `workDir` or `/var/run/github-runners/${name}` if (`workDir == null`). We have to create the logs directory before starting the service since launchd expects that the `Standard{Error,Out}Path` exist. We do this by prepending to [`system.activationScripts.launchd.text`]. All directories belong to the configured `user` and `group`. - Warn if a `tokenFile` points to the Nix store. [NixOS module]: https://github.com/NixOS/nixpkgs/blob/3c30c56/nixos/modules/services/continuous-integration/github-runner/options.nix [`system.activationScripts.launchd.text`]: https://github.com/LnL7/nix-darwin/blob/bbde06b/modules/system/launchd.nix#L99-L123 --- modules/services/github-runner/config.nix | 79 --------- modules/services/github-runner/default.nix | 33 +--- modules/services/github-runner/options.nix | 184 +++++++++++++++------ modules/services/github-runner/service.nix | 181 ++++++++++++++++++++ tests/services-github-runners.nix | 13 +- 5 files changed, 323 insertions(+), 167 deletions(-) delete mode 100644 modules/services/github-runner/config.nix create mode 100644 modules/services/github-runner/service.nix diff --git a/modules/services/github-runner/config.nix b/modules/services/github-runner/config.nix deleted file mode 100644 index 42c9eab7..00000000 --- a/modules/services/github-runner/config.nix +++ /dev/null @@ -1,79 +0,0 @@ -{ config, lib, pkgs, ... }: -let - mkSvcName = name: "github-runner-${name}"; - mkRootDir = name: "${config.users.users.github-runner.home}/.github-runner/${name}"; - mkWorkDir = name: "${mkRootDir name}/_work"; -in -with lib; -{ - launchd.daemons = flip mapAttrs' config.services.github-runners (name: cfg: - nameValuePair - (mkSvcName name) - (mkIf cfg.enable { - environment = { - RUNNER_ROOT = mkRootDir name; - } // cfg.extraEnvironment; - - # Minimal package set for `actions/checkout` - path = (with pkgs; [ - bash - coreutils - git - gnutar - gzip - ]) ++ [ - config.nix.package - ] ++ cfg.extraPackages; - - script = '' - echo "Configuring GitHub Actions Runner" - mkdir -p ${escapeShellArg (mkRootDir name)} - cd ${escapeShellArg (mkRootDir name)} - - args=( - --unattended - --disableupdate - --work ${escapeShellArg (mkWorkDir name)} - --url ${escapeShellArg cfg.url} - --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} - --name ${escapeShellArg cfg.name} - ${optionalString cfg.replace "--replace"} - ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"} - ${optionalString cfg.ephemeral "--ephemeral"} - ) - # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option, - # if it is not a PAT, we assume it contains a registration token and use the --token option - token=$(<"${cfg.tokenFile}") - if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then - args+=(--pat "$token") - else - args+=(--token "$token") - fi - ${cfg.package}/bin/config.sh "''${args[@]}" - - # Start the service - ${cfg.package}/bin/Runner.Listener run --startuptype service - ''; - - serviceConfig = mkMerge [ - { - KeepAlive = { - Crashed = false; - } // mkIf cfg.ephemeral { - SuccessfulExit = true; - }; - GroupName = "github-runner"; - ProcessType = "Interactive"; - RunAtLoad = true; - ThrottleInterval = 30; - UserName = "github-runner"; - WatchPaths = [ - "/etc/resolv.conf" - "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist" - ]; - WorkingDirectory = config.users.users.github-runner.home; - } - cfg.serviceOverrides - ]; - })); -} diff --git a/modules/services/github-runner/default.nix b/modules/services/github-runner/default.nix index 79379802..e860ecab 100644 --- a/modules/services/github-runner/default.nix +++ b/modules/services/github-runner/default.nix @@ -1,37 +1,6 @@ -{ config, lib, ... }: -let - anyEnabled = lib.any (cfg: cfg.enable) (lib.attrValues config.services.github-runners); -in { imports = [ ./options.nix - ./config.nix + ./service.nix ]; - - config.assertions = lib.mkIf anyEnabled [ - { - assertion = lib.elem "github-runner" config.users.knownGroups; - message = "set `users.knownGroups` to enable `github-runner` group"; - } - { - assertion = lib.elem "github-runner" config.users.knownUsers; - message = "set `users.knownUsers` to enable `github-runner` user"; - } - ]; - - config.users = lib.mkIf anyEnabled { - users."github-runner" = { - createHome = true; - uid = lib.mkDefault 533; - gid = lib.mkDefault config.users.groups.github-runner.gid; - home = lib.mkDefault "/var/lib/github-runners"; - shell = "/bin/bash"; - description = "GitHub Runner service user"; - }; - - groups."github-runner" = { - gid = lib.mkDefault 533; - description = "GitHub Runner service user group"; - }; - }; } diff --git a/modules/services/github-runner/options.nix b/modules/services/github-runner/options.nix index 5e421df9..300ca321 100644 --- a/modules/services/github-runner/options.nix +++ b/modules/services/github-runner/options.nix @@ -6,30 +6,46 @@ with lib; { options.services.github-runners = mkOption { - default = { }; description = mdDoc '' - Configure multiple GitHub Runners. - ''; - example = literalExpression '' - { - m1-runner = { - enable = true; - ephemeral = true; - replace = true; - tokenFile = "/secrets/github-org-pat.token"; - url = "https://github.com/nixos"; - }; + Multiple GitHub Runners. - m2-runner = { - enable = true; - extraLabels = [ "nixpkgs" ]; - replace = true; - tokenFile = "/secrets/github-repo-pat.token"; - url = "https://github.com/nixos/nixpkgs"; - }; - } + If `user` and `group` are set to `null`, the module will configure nix-darwin to + manage the `_github-runner` user and group. Note that multiple runner + configurations share the same user/group, which means they can access + resources from other runners. Make each runner use its own user and group if + this is not what you want. In this case, you will have to do the user and + group creation yourself. If only `user` is set, while `group` is set to + `null`, the service will infer the primary group of the `user`. + + For each GitHub runner, the system activation script creates the following + directories: + + * `/var/lib/github-runners/`: + State directory to store the runner registration credentials + * `/var/log/github-runners/`: + The launchd service writes the stdout and stderr streams to this + directory. + * `/var/run/github-runners/`: + Working directory for workflow files. The runner only uses this + directory if `workDir` is `null` (see the `workDir` option for details). ''; - type = with types; attrsOf (submodule ({ name, ... }: { + example = { + runner1 = { + enable = true; + url = "https://github.com/owner/repo"; + name = "runner1"; + tokenFile = "/secrets/token1"; + }; + + runner2 = { + enable = true; + url = "https://github.com/owner/repo"; + name = "runner2"; + tokenFile = "/secrets/token2"; + }; + }; + default = { }; + type = types.attrsOf (types.submodule ({ name, ... }: { options = { enable = mkOption { default = false; @@ -40,7 +56,7 @@ with lib; Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here: [About self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners). ''; - type = lib.types.bool; + type = types.bool; }; url = mkOption { @@ -64,25 +80,50 @@ with lib; tokenFile = mkOption { type = types.path; description = mdDoc '' - The full path to a file which contains either a runner registration token or a - (fine-grained) personal access token (PAT). - The file should contain exactly one line with the token without any newline. - If a registration token is given, it can be used to re-register a runner of the same - name but is time-limited. If the file contains a PAT, the service creates a new - registration token on startup as needed. Make sure the PAT has a scope of - `admin:org` for organization-wide registrations or a scope of - `repo` for a single repository. Fine-grained PATs need read and write permission - to the "Adminstration" resources. + The full path to a file which contains either - Changing this option or the file's content triggers a new runner registration. + * a fine-grained personal access token (PAT), + * a classic PAT + * or a runner registration token + + Changing this option or the `tokenFile`’s content triggers a new runner registration. + + We suggest using the fine-grained PATs. A runner registration token is valid + only for 1 hour after creation, so the next time the runner configuration changes + this will give you hard-to-debug HTTP 404 errors in the configure step. + + The file should contain exactly one line with the token without any newline. + (Use `echo -n '…token…' > …token file…` to make sure no newlines sneak in.) + + If the file contains a PAT, the service creates a new registration token + on startup as needed. + If a registration token is given, it can be used to re-register a runner of the same + name but is time-limited as noted above. + + For fine-grained PATs: + + Give it "Read and Write access to organization/repository self hosted runners", + depending on whether it is organization wide or per-repository. You might have to + experiment a little, fine-grained PATs are a `beta` Github feature and still subject + to change; nonetheless they are the best option at the moment. + + For classic PATs: + + Make sure the PAT has a scope of `admin:org` for organization-wide registrations + or a scope of `repo` for a single repository. + + For runner registration tokens: + + Nothing special needs to be done, but updating will break after one hour, + so these are not recommended. ''; example = "/run/secrets/github-runner/nixos.token"; }; name = mkOption { - type = types.str; + type = types.nullOr types.str; description = mdDoc '' - Name of the runner to configure. Defaults to the attribute name. + Name of the runner to configure. If null, defaults to the hostname. Changing this option triggers a new runner registration. ''; @@ -103,7 +144,7 @@ with lib; extraLabels = mkOption { type = types.listOf types.str; description = mdDoc '' - Extra labels in addition to the default (`["self-hosted", "Linux", "X64"]`). + Extra labels in addition to the default (unless disabled through the `noDefaultLabels` option). Changing this option triggers a new runner registration. ''; @@ -111,6 +152,16 @@ with lib; default = [ ]; }; + noDefaultLabels = mkOption { + type = types.bool; + description = mdDoc '' + Disables adding the default labels. Also see the `extraLabels` option. + + Changing this option triggers a new runner registration. + ''; + default = false; + }; + replace = mkOption { type = types.bool; description = mdDoc '' @@ -143,22 +194,12 @@ with lib; serviceOverrides = mkOption { type = types.attrs; description = mdDoc '' - Overrides for the systemd service. Can be used to adjust the sandboxing options. + Modify the service. Can be used to, e.g., adjust the sandboxing options. ''; - example = { - ProtectHome = false; - }; default = { }; }; - package = mkOption { - type = types.package; - description = mdDoc '' - Which github-runner derivation to use. - ''; - default = pkgs.github-runner; - defaultText = literalExpression "pkgs.github-runner"; - }; + package = mkPackageOptionMD pkgs "github-runner" { }; ephemeral = mkOption { type = types.bool; @@ -167,14 +208,61 @@ with lib; - Passes the `--ephemeral` flag to the runner configuration script - De-registers and stops the runner with GitHub after it has processed one job - - The runner wipes some state before it exists + - Restarts the service after its successful exit + - On start, wipes the state directory and configures a new runner You should only enable this option if `tokenFile` points to a file which contains a personal access token (PAT). If you're using the option with a registration token, restarting the service will fail as soon as the registration token expired. + + Changing this option triggers a new runner registration. ''; default = false; }; + + user = mkOption { + type = types.nullOr types.str; + description = mdDoc '' + User under which to run the service. + + If this option and the `group` option is set to `null`, nix-darwin creates + the `github-runner` user and group. + ''; + defaultText = literalExpression "username"; + default = null; + }; + + group = mkOption { + type = types.nullOr types.str; + description = mdDoc '' + Group under which to run the service. + + If this option and the `user` option is set to `null`, nix-darwin creates + the `github-runner` user and group. + ''; + defaultText = literalExpression "groupname"; + default = null; + }; + + workDir = mkOption { + type = with types; nullOr str; + description = mdDoc '' + Working directory, available as `$GITHUB_WORKSPACE` during workflow runs + and used as a default for [repository checkouts](https://github.com/actions/checkout). + The service cleans this directory on every service start. + + Changing this option triggers a new runner registration. + ''; + default = null; + }; + + nodeRuntimes = mkOption { + type = with types; nonEmptyListOf (enum [ "node20" ]); + default = [ "node20" ]; + description = mdDoc '' + List of Node.js runtimes the runner should support. + ''; + }; }; })); }; diff --git a/modules/services/github-runner/service.nix b/modules/services/github-runner/service.nix new file mode 100644 index 00000000..53f2cddb --- /dev/null +++ b/modules/services/github-runner/service.nix @@ -0,0 +1,181 @@ +{ config, lib, pkgs, ... }: +with lib; +let + mkSvcName = name: "github-runner-${name}"; + mkStateDir = cfg: "/var/lib/github-runners/${cfg.name}"; + mkLogDir = cfg: "/var/log/github-runners/${cfg.name}"; + mkWorkDir = cfg: if (cfg.workDir != null) then cfg.workDir else "/var/run/github-runners/${cfg.name}"; +in +{ + config.assertions = flatten ( + flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [ + { + assertion = (cfg.user == null && cfg.group == null) || (cfg.user != null); + message = "`services.github-runners.${name}`: Either set `user` and `group` to `null` to have nix-darwin manage them or set at least `user` explicitly"; + } + { + assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]); + message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set"; + } + ]) + ); + + config.warnings = flatten ( + flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [ + ( + mkIf (hasPrefix builtins.storeDir cfg.tokenFile) + "`services.github-runners.${name}`: `tokenFile` contains a secret but points to the world-readable Nix store." + ) + ]) + ); + + # Create the necessary directories and make the service user/group their owner + # This has to happen *after* nix-darwin user creation and *before* any launchd service gets started. + config.system.activationScripts = mkMerge (flip mapAttrsToList config.services.github-runners (name: cfg: + let + user = config.launchd.daemons.${mkSvcName name}.serviceConfig.UserName; + group = + if config.launchd.daemons.${mkSvcName name}.serviceConfig.GroupName != null + then config.launchd.daemons.${mkSvcName name}.serviceConfig.GroupName + else ""; + in + { + launchd = mkIf cfg.enable { + text = mkBefore ('' + echo >&2 "setting up GitHub Runner '${cfg.name}'..." + + ${pkgs.coreutils}/bin/mkdir -p -m 0750 ${escapeShellArg (mkStateDir cfg)} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${escapeShellArg (mkStateDir cfg)} + + ${pkgs.coreutils}/bin/mkdir -p -m 0750 ${escapeShellArg (mkLogDir cfg)} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${escapeShellArg (mkLogDir cfg)} + '' + optionalString (cfg.workDir == null) '' + ${pkgs.coreutils}/bin/mkdir -p -m 0750 ${escapeShellArg (mkWorkDir cfg)} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${escapeShellArg (mkWorkDir cfg)} + ''); + }; + })); + + config.launchd.daemons = flip mapAttrs' config.services.github-runners (name: cfg: + let + package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; }); + stateDir = mkStateDir cfg; + logDir = mkLogDir cfg; + workDir = mkWorkDir cfg; + in + nameValuePair + (mkSvcName name) + (mkIf cfg.enable { + environment = { + HOME = stateDir; + RUNNER_ROOT = stateDir; + } // cfg.extraEnvironment; + + # Minimal package set for `actions/checkout` + path = (with pkgs; [ + bash + coreutils + git + gnutar + gzip + ]) ++ [ + config.nix.package + ] ++ cfg.extraPackages; + + script = + let + configure = pkgs.writeShellApplication { + name = "configure-github-runner-${name}"; + text = '' + export RUNNER_ROOT + + args=( + --unattended + --disableupdate + --work ${escapeShellArg workDir} + --url ${escapeShellArg cfg.url} + --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} + ${optionalString (cfg.name != null ) "--name ${escapeShellArg cfg.name}"} + ${optionalString cfg.replace "--replace"} + ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"} + ${optionalString cfg.ephemeral "--ephemeral"} + ${optionalString cfg.noDefaultLabels "--no-default-labels"} + ) + # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option, + # if it is not a PAT, we assume it contains a registration token and use the --token option + token=$(<"${cfg.tokenFile}") + if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then + args+=(--pat "$token") + else + args+=(--token "$token") + fi + ${package}/bin/config.sh "''${args[@]}" + ''; + }; + in + '' + echo "Configuring GitHub Actions Runner" + + # Always clean the working directory + ${pkgs.findutils}/bin/find ${escapeShellArg workDir} -mindepth 1 -delete + + # Clean the $RUNNER_ROOT if we are in ephemeral mode + if ${boolToString cfg.ephemeral}; then + echo "Cleaning $RUNNER_ROOT" + ${pkgs.findutils}/bin/find "$RUNNER_ROOT" -mindepth 1 -delete + fi + + # If the `.runner` file does not exist, we assume the runner is not configured + if [[ ! -f "$RUNNER_ROOT/.runner" ]]; then + ${getExe configure} + fi + + # Start the service + ${package}/bin/Runner.Listener run --startuptype service + ''; + + serviceConfig = mkMerge [ + { + GroupName = cfg.group; + KeepAlive = { + Crashed = false; + } // mkIf cfg.ephemeral { + SuccessfulExit = true; + }; + ProcessType = "Interactive"; + RunAtLoad = true; + StandardErrorPath = "${logDir}/launchd-stderr.log"; + StandardOutPath = "${logDir}/launchd-stdout.log"; + ThrottleInterval = 30; + UserName = if (cfg.user != null) then cfg.user else "_github-runner"; + WatchPaths = [ + "/etc/resolv.conf" + "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist" + cfg.tokenFile + ]; + WorkingDirectory = stateDir; + } + cfg.serviceOverrides + ]; + })); + + # If any GitHub runner configuration has set both `user` and `group` set to `null`, + # manage the user and group `_github-runner` through nix-darwin. + config.users = mkIf (any (cfg: cfg.enable && cfg.user == null && cfg.group == null) (attrValues config.services.github-runners)) { + users."_github-runner" = { + createHome = false; + description = "GitHub Runner service user"; + gid = config.users.groups."_github-runner".gid; + home = "/var/lib/github-runners"; + shell = "/bin/bash"; + uid = mkDefault 533; + }; + knownUsers = [ "_github-runner" ]; + + groups."_github-runner" = { + gid = mkDefault 533; + description = "GitHub Runner service user group"; + }; + knownGroups = [ "_github-runner" ]; + }; +} diff --git a/tests/services-github-runners.nix b/tests/services-github-runners.nix index fc571027..55787505 100644 --- a/tests/services-github-runners.nix +++ b/tests/services-github-runners.nix @@ -1,21 +1,18 @@ { config, pkgs, ... }: { - users = { - knownUsers = [ "github-runner" ]; - knownGroups = [ "github-runner" ]; - }; - services.github-runners."a-runner" = { enable = true; url = "https://github.com/nixos/nixpkgs"; - tokenFile = pkgs.writeText "fake-token" "not-a-token"; - package = pkgs.runCommand "github-runner-0.0.0" { } "touch $out"; + tokenFile = "/secret/path/to/a/github/token"; + # We need an overridable derivation but cannot use the actual github-runner package + # since it still relies on Node.js 16 which is marked as insecure. + package = pkgs.hello; }; test = '' echo >&2 "checking github-runner service in /Library/LaunchDaemons" grep "org.nixos.github-runner-a-runner" ${config.out}/Library/LaunchDaemons/org.nixos.github-runner-a-runner.plist - grep "github-runner" ${config.out}/Library/LaunchDaemons/org.nixos.github-runner-a-runner.plist + grep "_github-runner" ${config.out}/Library/LaunchDaemons/org.nixos.github-runner-a-runner.plist echo >&2 "checking for user in /activate" grep "GitHub Runner service user" ${config.out}/activate