diff options
Diffstat (limited to 'nixos/modules/services/continuous-integration/github-runner.nix')
-rw-r--r-- | nixos/modules/services/continuous-integration/github-runner.nix | 310 |
1 files changed, 310 insertions, 0 deletions
diff --git a/nixos/modules/services/continuous-integration/github-runner.nix b/nixos/modules/services/continuous-integration/github-runner.nix new file mode 100644 index 00000000000..a7645e1f56e --- /dev/null +++ b/nixos/modules/services/continuous-integration/github-runner.nix @@ -0,0 +1,310 @@ +{ config, pkgs, lib, ... }: +with lib; +let + cfg = config.services.github-runner; + svcName = "github-runner"; + systemdDir = "${svcName}/${cfg.name}"; + # %t: Runtime directory root (usually /run); see systemd.unit(5) + runtimeDir = "%t/${systemdDir}"; + # %S: State directory root (usually /var/lib); see systemd.unit(5) + stateDir = "%S/${systemdDir}"; + # %L: Log directory root (usually /var/log); see systemd.unit(5) + logsDir = "%L/${systemdDir}"; + # Name of file stored in service state directory + currentConfigTokenFilename = ".current-token"; +in +{ + options.services.github-runner = { + enable = mkOption { + default = false; + example = true; + description = '' + Whether to enable GitHub Actions runner. + + Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here: + <link xlink:href="https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners" + >About self-hosted runners</link>. + ''; + type = lib.types.bool; + }; + + url = mkOption { + type = types.str; + description = '' + Repository to add the runner to. + + Changing this option triggers a new runner registration. + + IMPORTANT: If your token is org-wide (not per repository), you need to + provide a github org link, not a single repository, so do it like this + <literal>https://github.com/nixos</literal>, not like this + <literal>https://github.com/nixos/nixpkgs</literal>. + Otherwise, you are going to get a <literal>404 NotFound</literal> + from <literal>POST https://api.github.com/actions/runner-registration</literal> + in the configure script. + ''; + example = "https://github.com/nixos/nixpkgs"; + }; + + tokenFile = mkOption { + type = types.path; + description = '' + The full path to a file which contains the runner registration token. + The file should contain exactly one line with the token without any newline. + The token can be used to re-register a runner of the same name but is time-limited. + + Changing this option or the file's content triggers a new runner registration. + ''; + example = "/run/secrets/github-runner/nixos.token"; + }; + + name = mkOption { + # Same pattern as for `networking.hostName` + type = types.strMatching "^$|^[[:alnum:]]([[:alnum:]_-]{0,61}[[:alnum:]])?$"; + description = '' + Name of the runner to configure. Defaults to the hostname. + + Changing this option triggers a new runner registration. + ''; + example = "nixos"; + default = config.networking.hostName; + defaultText = literalExpression "config.networking.hostName"; + }; + + runnerGroup = mkOption { + type = types.nullOr types.str; + description = '' + Name of the runner group to add this runner to (defaults to the default runner group). + + Changing this option triggers a new runner registration. + ''; + default = null; + }; + + extraLabels = mkOption { + type = types.listOf types.str; + description = '' + Extra labels in addition to the default (<literal>["self-hosted", "Linux", "X64"]</literal>). + + Changing this option triggers a new runner registration. + ''; + example = literalExpression ''[ "nixos" ]''; + default = [ ]; + }; + + replace = mkOption { + type = types.bool; + description = '' + Replace any existing runner with the same name. + + Without this flag, registering a new runner with the same name fails. + ''; + default = false; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + description = '' + Extra packages to add to <literal>PATH</literal> of the service to make them available to workflows. + ''; + default = [ ]; + }; + + package = mkOption { + type = types.package; + description = '' + Which github-runner derivation to use. + ''; + default = pkgs.github-runner; + defaultText = literalExpression "pkgs.github-runner"; + }; + }; + + config = mkIf cfg.enable { + warnings = optionals (isStorePath cfg.tokenFile) [ + '' + `services.github-runner.tokenFile` points to the Nix store and, therefore, is world-readable. + Consider using a path outside of the Nix store to keep the token private. + '' + ]; + + systemd.services.${svcName} = { + description = "GitHub Actions runner"; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network.target" "network-online.target" ]; + + environment = { + HOME = runtimeDir; + RUNNER_ROOT = runtimeDir; + }; + + path = (with pkgs; [ + bash + coreutils + git + gnutar + gzip + ]) ++ [ + config.nix.package + ] ++ cfg.extraPackages; + + serviceConfig = rec { + ExecStart = "${cfg.package}/bin/runsvc.sh"; + + # Does the following, sequentially: + # - If the module configuration or the token has changed, purge the state directory, + # and create the current and the new token file with the contents of the configured + # token. While both files have the same content, only the later is accessible by + # the service user. + # - Configure the runner using the new token file. When finished, delete it. + # - Set up the directory structure by creating the necessary symlinks. + ExecStartPre = + let + # Wrapper script which expects the full path of the state, runtime and logs + # directory as arguments. Overrides the respective systemd variables to provide + # unambiguous directory names. This becomes relevant, for example, if the + # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory= + # to contain more than one directory. This causes systemd to set the respective + # environment variables with the path of all of the given directories, separated + # by a colon. + writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" '' + set -euo pipefail + + STATE_DIRECTORY="$1" + RUNTIME_DIRECTORY="$2" + LOGS_DIRECTORY="$3" + + ${lines} + ''; + currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json"; + runnerRegistrationConfig = getAttrs [ "name" "tokenFile" "url" "runnerGroup" "extraLabels" ] cfg; + newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig); + newConfigTokenFilename = ".new-token"; + runnerCredFiles = [ + ".credentials" + ".credentials_rsaparams" + ".runner" + ]; + unconfigureRunner = writeScript "unconfigure" '' + differs= + # Set `differs = 1` if current and new runner config differ or if `currentConfigPath` does not exist + ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 || differs=1 + # Also trigger a registration if the token content changed + ${pkgs.diffutils}/bin/diff -q \ + "$STATE_DIRECTORY"/${currentConfigTokenFilename} \ + ${escapeShellArg cfg.tokenFile} \ + >/dev/null 2>&1 || differs=1 + + if [[ -n "$differs" ]]; then + echo "Config has changed, removing old runner state." + echo "The old runner will still appear in the GitHub Actions UI." \ + "You have to remove it manually." + find "$STATE_DIRECTORY/" -mindepth 1 -delete + + # Copy the configured token file to the state dir and allow the service user to read the file + install --mode=666 ${escapeShellArg cfg.tokenFile} "$STATE_DIRECTORY/${newConfigTokenFilename}" + # Also copy current file to allow for a diff on the next start + install --mode=600 ${escapeShellArg cfg.tokenFile} "$STATE_DIRECTORY/${currentConfigTokenFilename}" + fi + ''; + configureRunner = writeScript "configure" '' + if [[ -e "$STATE_DIRECTORY/${newConfigTokenFilename}" ]]; then + echo "Configuring GitHub Actions Runner" + + token=$(< "$STATE_DIRECTORY"/${newConfigTokenFilename}) + RUNNER_ROOT="$STATE_DIRECTORY" ${cfg.package}/bin/config.sh \ + --unattended \ + --disableupdate \ + --work "$RUNTIME_DIRECTORY" \ + --url ${escapeShellArg cfg.url} \ + --token "$token" \ + --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)} \ + --name ${escapeShellArg cfg.name} \ + ${optionalString cfg.replace "--replace"} \ + ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"} + + # Move the automatically created _diag dir to the logs dir + mkdir -p "$STATE_DIRECTORY/_diag" + cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/" + rm -rf "$STATE_DIRECTORY/_diag/" + + # Cleanup token from config + rm "$STATE_DIRECTORY/${newConfigTokenFilename}" + + # Symlink to new config + ln -s '${newConfigPath}' "${currentConfigPath}" + fi + ''; + setupRuntimeDir = writeScript "setup-runtime-dirs" '' + # Link _diag dir + ln -s "$LOGS_DIRECTORY" "$RUNTIME_DIRECTORY/_diag" + + # Link the runner credentials to the runtime dir + ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$RUNTIME_DIRECTORY/" + ''; + in + map (x: "${x} ${escapeShellArgs [ stateDir runtimeDir logsDir ]}") [ + "+${unconfigureRunner}" # runs as root + configureRunner + setupRuntimeDir + ]; + + # Contains _diag + LogsDirectory = [ systemdDir ]; + # Default RUNNER_ROOT which contains ephemeral Runner data + RuntimeDirectory = [ systemdDir ]; + # Home of persistent runner data, e.g., credentials + StateDirectory = [ systemdDir ]; + StateDirectoryMode = "0700"; + WorkingDirectory = runtimeDir; + + InaccessiblePaths = [ + # Token file path given in the configuration + cfg.tokenFile + # Token file in the state directory + "${stateDir}/${currentConfigTokenFilename}" + ]; + + # By default, use a dynamically allocated user + DynamicUser = true; + + KillMode = "process"; + KillSignal = "SIGTERM"; + + # Hardening (may overlap with DynamicUser=) + # The following options are only for optimizing: + # systemd-analyze security github-runner + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + UMask = "0066"; + + # Needs network access + PrivateNetwork = false; + # Cannot be true due to Node + MemoryDenyWriteExecute = false; + }; + }; + }; +} |