diff --git a/nixos/modules/services/continuous-integration/github-runner/app-token.sh b/nixos/modules/services/continuous-integration/github-runner/app-token.sh new file mode 100644 index 000000000000..01f4abb748ea --- /dev/null +++ b/nixos/modules/services/continuous-integration/github-runner/app-token.sh @@ -0,0 +1,88 @@ +# shellcheck shell=bash +# Request an installation access token for a GitHub App. +# +# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT, +# Copyright (c) 2020 Marcus Young), see: +# https://github.com/orgs/community/discussions/24743#discussioncomment-3245300 +# +# Expects the following environment variables: +# * APP_ID the GitHub App's ID +# * APP_PRIVATE_KEY the GitHub App's PEM-encoded private key (contents) +# * APP_LOGIN the org/user login the App is installed on +# * GITHUB_HOST optional, defaults to github.com (set for GHES) +# +# Prints the installation access token (prefixed with `ghs_`) to stdout. + +_GITHUB_HOST=${GITHUB_HOST:="github.com"} + +# If the host is not github.com, use the GitHub Enterprise Server API endpoint. +if [[ ${_GITHUB_HOST} == "github.com" ]]; then + URI="https://api.${_GITHUB_HOST}" +else + URI="https://${_GITHUB_HOST}/api/v3" +fi + +API_VERSION=v3 +API_HEADER="Accept: application/vnd.github.${API_VERSION}+json" +CONTENT_LENGTH_HEADER="Content-Length: 0" +APP_INSTALLATIONS_URI="${URI}/app/installations" + +# JWT token issuance and expiration parameters, see: +# https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app +JWT_IAT_DRIFT=60 +JWT_EXP_DELTA=600 + +JWT_JOSE_HEADER='{ + "alg": "RS256", + "typ": "JWT" +}' + +build_jwt_payload() { + now=$(date +%s) + iat=$((now - JWT_IAT_DRIFT)) + jq -c \ + --arg iat_str "${iat}" \ + --arg exp_delta_str "${JWT_EXP_DELTA}" \ + --arg app_id_str "${APP_ID}" \ + ' + ($iat_str | tonumber) as $iat + | ($exp_delta_str | tonumber) as $exp_delta + | ($app_id_str | tonumber) as $app_id + | .iat = $iat + | .exp = ($iat + $exp_delta) + | .iss = $app_id + ' <<<"{}" | tr -d '\n' +} + +base64url() { + base64 | tr '+/' '-_' | tr -d '=\n' +} + +rs256_sign() { + openssl dgst -binary -sha256 -sign <(echo "$1") +} + +request_access_token() { + jwt_payload=$(build_jwt_payload) + encoded_jwt_parts=$(base64url <<<"${JWT_JOSE_HEADER}").$(base64url <<<"${jwt_payload}") + encoded_mac=$(echo -n "${encoded_jwt_parts}" | rs256_sign "${APP_PRIVATE_KEY}" | base64url) + generated_jwt="${encoded_jwt_parts}.${encoded_mac}" + + auth_header="Authorization: Bearer ${generated_jwt}" + + app_installations_response=$( + curl -fsSX GET \ + -H "${auth_header}" \ + -H "${API_HEADER}" \ + "${APP_INSTALLATIONS_URI}" + ) + access_token_url=$(echo "${app_installations_response}" | jq --raw-output '.[] | select (.account.login == "'"${APP_LOGIN}"'" and .app_id == '"${APP_ID}"') .access_tokens_url') + curl -fsSX POST \ + -H "${CONTENT_LENGTH_HEADER}" \ + -H "${auth_header}" \ + -H "${API_HEADER}" \ + "${access_token_url}" | + jq --raw-output .token +} + +request_access_token diff --git a/nixos/modules/services/continuous-integration/github-runner/options.nix b/nixos/modules/services/continuous-integration/github-runner/options.nix index e571a96ccb86..b96513baddc0 100644 --- a/nixos/modules/services/continuous-integration/github-runner/options.nix +++ b/nixos/modules/services/continuous-integration/github-runner/options.nix @@ -42,7 +42,8 @@ }; url = lib.mkOption { - type = lib.types.str; + type = lib.types.nullOr lib.types.str; + default = null; description = '' Repository to add the runner to. @@ -55,12 +56,16 @@ Otherwise, you are going to get a `404 NotFound` from `POST https://api.github.com/actions/runner-registration` in the configure script. + + Mandatory unless `orgs` is used, in which case the URL is taken + from each `orgs..url` instead and this option is ignored. ''; example = "https://github.com/nixos/nixpkgs"; }; tokenFile = lib.mkOption { - type = lib.types.path; + type = lib.types.nullOr lib.types.path; + default = null; description = '' The full path to a file which contains either @@ -68,6 +73,10 @@ * a classic PAT * or a runner registration token + Exactly one of `tokenFile` and `githubApp` must be set. Use + `githubApp` to authenticate via a GitHub App installation instead + of a token file. + 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 @@ -126,6 +135,71 @@ default = "auto"; }; + githubApp = lib.mkOption { + default = null; + description = '' + Authenticate the runner using a GitHub App installation instead + of a `tokenFile`. Exactly one of `tokenFile` and `githubApp` must + be set. + + On every start the service derives a short-lived installation + access token from the App's private key, uses it to fetch a fresh + runner registration token and registers the runner with it. This + avoids storing a long-lived personal access token on the host and + pairs well with `ephemeral` runners. + + The App needs read and write access to the + "self-hosted runners" administration of the organisation (or + repository) given in `url`, and must be installed on the `login` + below. + ''; + example = lib.literalExpression '' + { + id = 123456; + login = "my-org"; + privateKeyFile = "/run/secrets/github-app.pem"; + } + ''; + type = lib.types.nullOr ( + lib.types.submodule { + options = { + id = lib.mkOption { + type = lib.types.int; + description = "The GitHub App's ID (the numeric `App ID`, not the client ID)."; + example = 123456; + }; + + login = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The organisation (or user) login the GitHub App is + installed on. Used to look up the App installation and, + for organisation-wide runners, as the registration scope. + + Mandatory unless `orgs` is used, in which case the login + is taken from each `orgs..login` instead and this + option is ignored. + + Changing this option triggers a new runner registration. + ''; + example = "my-org"; + }; + + privateKeyFile = lib.mkOption { + type = lib.types.path; + description = '' + The full path to a file containing the GitHub App's + PEM-encoded private key. The file should be deployed as a + secret and is never copied into the Nix store. + ''; + example = "/run/secrets/github-app.pem"; + }; + }; + } + ); + }; + name = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' @@ -137,6 +211,107 @@ default = name; }; + count = lib.mkOption { + type = lib.types.ints.positive; + default = 1; + example = 4; + description = '' + Number of identical runner instances to create. + + Without `orgs`, this fans the single entry (using the + entry-level `url`) out into `count` runner services: + `github-runner-` for `count == 1` (unchanged) and + `github-runner--` for `count > 1`. Each instance + registers under a distinct runner name. + + With `orgs`, this is the default replica count for every org + that does not set its own; see `orgs..count`. + + Pairs well with `ephemeral`. + ''; + }; + + orgs = lib.mkOption { + default = { }; + description = '' + Organisations (or repositories) to serve from this entry. + + When set, the entry fans out into one systemd service per runner + named `github-runner---`, where `` is the + attribute name and `` ranges over the per-org `count`. The + entry-level `githubApp`/`tokenFile` is shared across every org; + only the App `login` changes per org (defaulting to the + attribute name), so a single GitHub App installed on multiple + orgs serves all of them. + + Leaving this empty (the default) keeps the single-runner + behaviour: the entry-level `url`, `name` and auth, fanned out by + the entry-level `count`. + ''; + example = lib.literalExpression '' + { + org-a.count = 12; + org-b = { + count = 2; + extraLabels = [ "org-b" ]; + }; + } + ''; + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, ... }: + { + options = { + url = lib.mkOption { + type = lib.types.str; + default = "https://github.com/${name}"; + defaultText = lib.literalExpression ''"https://github.com/''${name}"''; + description = '' + URL of the organisation (or repository) to connect to. + Defaults to the GitHub URL derived from the attribute name. + ''; + }; + + login = lib.mkOption { + type = lib.types.str; + default = name; + defaultText = lib.literalExpression "\${name}"; + description = '' + GitHub login (org or user) the shared `githubApp` is + installed on. Defaults to the attribute name. Ignored + when authenticating via `tokenFile`. + ''; + }; + + count = lib.mkOption { + type = lib.types.ints.positive; + default = config.count; + defaultText = lib.literalMD "the entry-level `count`"; + example = 4; + description = '' + Number of identical runner instances to create for this + org. Defaults to the entry-level `count`. Each gets its + own systemd service named `github-runner---` + and registers under a distinct runner name. Pairs well + with `ephemeral`. + ''; + }; + + extraLabels = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = lib.literalExpression ''[ "org-a" ]''; + description = '' + Extra labels added, on top of the entry-level `extraLabels`, + only to this org's runners. + ''; + }; + }; + } + ) + ); + }; + runnerGroup = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' diff --git a/nixos/modules/services/continuous-integration/github-runner/registration-token.sh b/nixos/modules/services/continuous-integration/github-runner/registration-token.sh new file mode 100644 index 000000000000..1093a31415b2 --- /dev/null +++ b/nixos/modules/services/continuous-integration/github-runner/registration-token.sh @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# Fetch a self-hosted runner registration token from the GitHub API. +# +# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT, +# Copyright (c) 2020 Marcus Young). +# +# Expects the following environment variables: +# * ACCESS_TOKEN a token authorized to manage self-hosted runners +# (a GitHub App installation token or a suitable PAT) +# * RUNNER_SCOPE one of `org`, `ent` or `repo` +# * ORG_NAME the org login (for `org` scope) +# * ENTERPRISE_NAME the enterprise slug (for `ent` scope) +# * REPO_URL the repository URL (for `repo` scope) +# * GITHUB_HOST optional, defaults to github.com (set for GHES) +# +# Prints `{"token": ..., "full_url": ...}` to stdout. + +_GITHUB_HOST=${GITHUB_HOST:="github.com"} + +# If the host is not github.com, use the GitHub Enterprise Server API endpoint. +if [[ ${_GITHUB_HOST} == "github.com" ]]; then + URI="https://api.${_GITHUB_HOST}" +else + URI="https://${_GITHUB_HOST}/api/v3" +fi + +API_VERSION=v3 +API_HEADER="Accept: application/vnd.github.${API_VERSION}+json" +AUTH_HEADER="Authorization: token ${ACCESS_TOKEN}" +CONTENT_LENGTH_HEADER="Content-Length: 0" + +case ${RUNNER_SCOPE} in +org*) + _FULL_URL="${URI}/orgs/${ORG_NAME}/actions/runners/registration-token" + ;; + +ent*) + _FULL_URL="${URI}/enterprises/${ENTERPRISE_NAME}/actions/runners/registration-token" + ;; + +*) + _PROTO="https://" + _URL="${REPO_URL/${_PROTO}/}" + _PATH="$(echo "${_URL}" | grep / | cut -d/ -f2-)" + _ACCOUNT="$(echo "${_PATH}" | cut -d/ -f1)" + _REPO="$(echo "${_PATH}" | cut -d/ -f2)" + _FULL_URL="${URI}/repos/${_ACCOUNT}/${_REPO}/actions/runners/registration-token" + ;; +esac + +RUNNER_TOKEN="$(curl -fsSX POST \ + -H "${CONTENT_LENGTH_HEADER}" \ + -H "${AUTH_HEADER}" \ + -H "${API_HEADER}" \ + "${_FULL_URL}" | + jq -r '.token')" + +echo "{\"token\": \"${RUNNER_TOKEN}\", \"full_url\": \"${_FULL_URL}\"}" diff --git a/nixos/modules/services/continuous-integration/github-runner/remove-runner.sh b/nixos/modules/services/continuous-integration/github-runner/remove-runner.sh new file mode 100644 index 000000000000..f492df079d62 --- /dev/null +++ b/nixos/modules/services/continuous-integration/github-runner/remove-runner.sh @@ -0,0 +1,71 @@ +# shellcheck shell=bash +# Force-remove a previously registered, offline self-hosted runner via the +# GitHub API. Used before re-registering to avoid orphaned runners piling up in +# the GitHub Actions UI. +# +# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT, +# Copyright (c) 2020 Marcus Young). +# +# Expects the following environment variables: +# * ACCESS_TOKEN a token authorized to manage self-hosted runners +# * RUNNER_NAME the name of the runner to remove +# * RUNNER_SCOPE one of `org`, `ent` or `repo` +# * ORG_NAME the org login (for `org` scope) +# * ENTERPRISE_NAME the enterprise slug (for `ent` scope) +# * REPO_URL the repository URL (for `repo` scope) +# * GITHUB_HOST optional, defaults to github.com (set for GHES) + +_GITHUB_HOST=${GITHUB_HOST:="github.com"} + +# If the host is not github.com, use the GitHub Enterprise Server API endpoint. +if [[ ${_GITHUB_HOST} == "github.com" ]]; then + URI="https://api.${_GITHUB_HOST}" +else + URI="https://${_GITHUB_HOST}/api/v3" +fi + +API_HEADER="Accept: application/vnd.github+json" +AUTH_HEADER="Authorization: token ${ACCESS_TOKEN}" +CONTENT_LENGTH_HEADER="Content-Length: 0" + +runners_url() { + case ${RUNNER_SCOPE} in + org*) + echo "${URI}/orgs/${ORG_NAME}/actions/runners" + ;; + ent*) + echo "${URI}/enterprises/${ENTERPRISE_NAME}/actions/runners" + ;; + *) + _PROTO="https://" + _URL="${REPO_URL/${_PROTO}/}" + _PATH="$(echo "${_URL}" | grep / | cut -d/ -f2-)" + _ACCOUNT="$(echo "${_PATH}" | cut -d/ -f1)" + _REPO="$(echo "${_PATH}" | cut -d/ -f2)" + echo "${URI}/repos/${_ACCOUNT}/${_REPO}/actions/runners" + ;; + esac +} + +_RUNNERS_URL="$(runners_url)" + +RUNNERS="$(curl -fsSX GET \ + -H "${CONTENT_LENGTH_HEADER}" \ + -H "${AUTH_HEADER}" \ + -H "${API_HEADER}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${_RUNNERS_URL}")" +RUNNER_ID=$(echo "$RUNNERS" | jq -r '.runners[] | select( (.name == env.RUNNER_NAME) and (.status == "offline") ) | .id') + +if [[ $RUNNER_ID == "" ]]; then + echo "Runner ${RUNNER_NAME} doesn't exist or is online. Nothing to unregister." + exit 0 +fi +echo "${RUNNER_NAME} is still registered and offline. Forcing removal..." + +curl -fsSX DELETE \ + -H "${CONTENT_LENGTH_HEADER}" \ + -H "${AUTH_HEADER}" \ + -H "${API_HEADER}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${_RUNNERS_URL}/${RUNNER_ID}" diff --git a/nixos/modules/services/continuous-integration/github-runner/service.nix b/nixos/modules/services/continuous-integration/github-runner/service.nix index 2a0720a6b0c7..1b2bed98e23a 100644 --- a/nixos/modules/services/continuous-integration/github-runner/service.nix +++ b/nixos/modules/services/continuous-integration/github-runner/service.nix @@ -9,6 +9,22 @@ lib.flip lib.mapAttrsToList config.services.github-runners ( name: cfg: map (lib.mkIf cfg.enable) [ + { + assertion = (cfg.tokenFile == null) != (cfg.githubApp == null); + message = "`services.github-runners.${name}`: Exactly one of `tokenFile` and `githubApp` must be set"; + } + { + assertion = cfg.orgs != { } || cfg.url != null; + message = "`services.github-runners.${name}`: `url` must be set unless `orgs` is used"; + } + { + assertion = cfg.orgs != { } || cfg.githubApp == null || cfg.githubApp.login != null; + message = "`services.github-runners.${name}`: `githubApp.login` must be set unless `orgs` is used (the login is then derived per org)"; + } + { + assertion = cfg.orgs != { } || cfg.count == 1 || cfg.name != null; + message = "`services.github-runners.${name}`: `name` must not be null when `count > 1` (each replica needs a distinct registration name)"; + } { assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]); message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set"; @@ -24,8 +40,49 @@ config.systemd.services = let enabledRunners = lib.filterAttrs (_: cfg: cfg.enable) config.services.github-runners; + runnerInstances = lib.concatMapAttrs ( + name: cfg: + if cfg.orgs == { } then + # Single org/repo (entry-level `url`), fanned out by `count`. For + # `count == 1` the service keeps the bare `github-runner-` name + # for backwards compatibility; `count > 1` suffixes `-`. + let + suffixes = if cfg.count == 1 then [ "" ] else map (n: "-${toString n}") (lib.range 1 cfg.count); + in + lib.listToAttrs ( + map ( + suffix: + lib.nameValuePair "${name}${suffix}" ( + cfg // { name = if cfg.name == null then null else "${cfg.name}${suffix}"; } + ) + ) suffixes + ) + else + lib.listToAttrs ( + lib.concatLists ( + lib.flip lib.mapAttrsToList cfg.orgs ( + orgName: org: + map ( + n: + let + key = "${name}-${orgName}-${toString n}"; + in + lib.nameValuePair key ( + cfg + // { + url = org.url; + name = key; + extraLabels = cfg.extraLabels ++ org.extraLabels; + githubApp = if cfg.githubApp == null then null else cfg.githubApp // { login = org.login; }; + } + ) + ) (lib.range 1 org.count) + ) + ) + ) + ) enabledRunners; in - (lib.flip lib.mapAttrs' enabledRunners ( + (lib.flip lib.mapAttrs' runnerInstances ( name: cfg: let svcName = "github-runner-${name}"; @@ -41,6 +98,66 @@ currentConfigTokenFilename = ".current-token"; workDir = if cfg.workDir == null then runtimeDir else cfg.workDir; + + newConfigTokenPath = "$STATE_DIRECTORY/.new-token"; + currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}"; + + # Wrapper script which expects the full path of the state, working 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 = + scriptName: lines: + pkgs.writeShellScript "${svcName}-${scriptName}.sh" '' + set -euo pipefail + + STATE_DIRECTORY="$1" + WORK_DIRECTORY="$2" + LOGS_DIRECTORY="$3" + + ${lines} + ''; + + ghUrlPath = lib.removePrefix "https://github.com/" cfg.url; + ghUrlSegments = lib.filter (s: s != "") (lib.splitString "/" ghUrlPath); + runnerScope = if lib.length ghUrlSegments >= 2 then "repo" else "org"; + appHelper = + helperName: scriptFile: + pkgs.writeShellApplication { + name = helperName; + runtimeInputs = with pkgs; [ + jq + curl + openssl + coreutils + ]; + excludeShellChecks = [ + "SC2154" + "SC2116" + ]; + text = builtins.readFile scriptFile; + }; + fetchAccessToken = appHelper "github-app-access-token" ./app-token.sh; + fetchRegistrationToken = appHelper "github-runner-registration-token" ./registration-token.sh; + removeRunner = appHelper "github-runner-remove" ./remove-runner.sh; + appEnv = lib.optionalString (cfg.githubApp != null) '' + export APP_ID=${toString cfg.githubApp.id} + export APP_LOGIN=${lib.escapeShellArg cfg.githubApp.login} + APP_PRIVATE_KEY="$(cat ${lib.escapeShellArg cfg.githubApp.privateKeyFile})" + export APP_PRIVATE_KEY + export RUNNER_SCOPE=${runnerScope} + export ORG_NAME=${lib.escapeShellArg cfg.githubApp.login} + export REPO_URL=${lib.escapeShellArg cfg.url} + ${ + if cfg.name != null then + "export RUNNER_NAME=${lib.escapeShellArg cfg.name}" + else + ''export RUNNER_NAME="$(uname -n)"'' + } + ''; in lib.nameValuePair svcName { description = "GitHub Actions runner"; @@ -84,24 +201,6 @@ # - Set up the directory structure by creating the necessary symlinks. ExecStartPre = let - # Wrapper script which expects the full path of the state, working 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" - WORK_DIRECTORY="$2" - LOGS_DIRECTORY="$3" - - ${lines} - ''; runnerRegistrationConfig = lib.getAttrs [ "ephemeral" "extraLabels" @@ -114,8 +213,6 @@ ] cfg; newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig); currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json"; - newConfigTokenPath = "$STATE_DIRECTORY/.new-token"; - currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}"; runnerCredFiles = [ ".credentials" @@ -164,6 +261,23 @@ # Always clean workDir find -H "$WORK_DIRECTORY" -mindepth 1 -delete ''; + unconfigureRunnerGitHubApp = writeScript "unconfigure-github-app" '' + ${appEnv} + ACCESS_TOKEN="$(${lib.getExe' fetchAccessToken "github-app-access-token"})" + export ACCESS_TOKEN + + ${lib.getExe' removeRunner "github-runner-remove"} || true + + find "$STATE_DIRECTORY/" -mindepth 1 -delete + + umask 000 + ${lib.getExe' fetchRegistrationToken "github-runner-registration-token"} \ + | ${pkgs.jq}/bin/jq -r '.token' > "${newConfigTokenPath}" + install --mode=600 "${newConfigTokenPath}" "${currentConfigTokenPath}" + + # Always clean workDir + find -H "$WORK_DIRECTORY" -mindepth 1 -delete + ''; configureRunner = writeScript "configure" # bash '' @@ -185,24 +299,33 @@ ${lib.optionalString cfg.noDefaultLabels "--no-default-labels"} ) token=$(<"${newConfigTokenPath}") - case ${cfg.tokenType} in - access) - args+=(--pat "$token") - ;; - registration) - args+=(--token "$token") - ;; - auto) - # 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 - if [[ "$token" =~ ^gh[a-z]+_* ]] || [[ "$token" =~ ^github_pat_* ]]; then - args+=(--pat "$token") + ${ + if cfg.githubApp != null then + '' + args+=(--token "$token") + '' else - args+=(--token "$token") - fi - ;; - esac + '' + case ${cfg.tokenType} in + access) + args+=(--pat "$token") + ;; + registration) + args+=(--token "$token") + ;; + auto) + # 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 + if [[ "$token" =~ ^gh[a-z]+_* ]] || [[ "$token" =~ ^github_pat_* ]]; then + args+=(--pat "$token") + else + args+=(--token "$token") + fi + ;; + esac + '' + } ${cfg.package}/bin/Runner.Listener configure "''${args[@]}" # Move the automatically created _diag dir to the logs dir mkdir -p "$STATE_DIRECTORY/_diag" @@ -234,11 +357,33 @@ }" ) [ - "+${unconfigureRunner}" # runs as root + # runs as root + "+${if cfg.githubApp != null then unconfigureRunnerGitHubApp else unconfigureRunner}" configureRunner setupWorkDir ]; + ExecStopPost = lib.optionals (cfg.githubApp != null) ( + let + unregister = writeScript "unregister-github-app" '' + ${appEnv} + ACCESS_TOKEN="$(${lib.getExe' fetchAccessToken "github-app-access-token"})" + export ACCESS_TOKEN + ${lib.getExe' removeRunner "github-runner-remove"} || true + ''; + in + map ( + x: + "${x} ${ + lib.escapeShellArgs [ + stateDir + workDir + logsDir + ] + }" + ) [ "-+${unregister}" ] # runs as root + ); + # If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner) # to trigger a fresh registration. Restart = if cfg.ephemeral then "on-success" else "no"; @@ -256,11 +401,12 @@ WorkingDirectory = workDir; InaccessiblePaths = [ - # Token file path given in the configuration, if visible to the service - "-${cfg.tokenFile}" # Token file in the state directory "${stateDir}/${currentConfigTokenFilename}" - ]; + ] + # Token file path given in the configuration, if visible to the service + ++ lib.optional (cfg.tokenFile != null) "-${cfg.tokenFile}" + ++ lib.optional (cfg.githubApp != null) "-${cfg.githubApp.privateKeyFile}"; KillSignal = "SIGINT"; diff --git a/nixos/tests/github-runner.nix b/nixos/tests/github-runner.nix index 847da352ca6c..2748e49f21d1 100644 --- a/nixos/tests/github-runner.nix +++ b/nixos/tests/github-runner.nix @@ -6,6 +6,11 @@ }; nodes.machine = { pkgs, ... }: + let + appPrivateKey = pkgs.runCommand "github-app.pem" { + nativeBuildInputs = [ pkgs.openssl ]; + } "openssl genrsa -out $out 2048"; + in { services.github-runners.test = { enable = true; @@ -19,6 +24,55 @@ tokenFile = builtins.toFile "github-runner.token" "not-so-secret"; }; + # Runner authenticated via a GitHub App installation. This exercises the + # module evaluation and the App authentication code path up to the point it + # contacts the (stubbed) GitHub API; a successful registration would + # require talking to the real API. + services.github-runners.test-app = { + enable = true; + url = "https://github.com/yaxitech"; + githubApp = { + id = 123456; + login = "yaxitech"; + privateKeyFile = appPrivateKey; + }; + }; + + # A single org/repo entry with `count > 1` fans out into one systemd + # service per replica, named `github-runner--`. + services.github-runners.test-replicas = { + enable = true; + url = "https://github.com/yaxitech"; + tokenFile = builtins.toFile "github-runner.token" "not-so-secret"; + count = 2; + }; + + # A single entry with `orgs` fans out into one systemd service per org and + # per replica, named `github-runner---`. + services.github-runners.test-orgs = { + enable = true; + tokenFile = builtins.toFile "github-runner.token" "not-so-secret"; + orgs = { + yaxitech.count = 2; + another = { }; + }; + }; + + # `orgs` combined with a shared GitHub App: the same App is used for every + # org and only the per-org `login` changes (derived from the attribute + # name), so no entry-level `githubApp.login` is needed. + services.github-runners.test-orgs-app = { + enable = true; + githubApp = { + id = 123456; + privateKeyFile = appPrivateKey; + }; + orgs = { + yaxitech = { }; + "another-org" = { }; + }; + }; + systemd.services.dummy-github-com = { wantedBy = [ "multi-user.target" ]; before = [ "github-runner-test.service" ]; @@ -42,6 +96,26 @@ machine.wait_until_succeeds("test -f /tmp/registration-connect") + # The GitHub App runner unit is generated and wired to the App auth path. + machine.succeed("systemctl cat github-runner-test-app.service | grep -F unconfigure-github-app") + + # `count > 1` on a single entry fans out into one unit per replica with a + # `-` suffix; there is no bare unit. + machine.succeed("systemctl cat github-runner-test-replicas-1.service") + machine.succeed("systemctl cat github-runner-test-replicas-2.service") + machine.fail("systemctl cat github-runner-test-replicas.service") + + # `orgs` fans out into one unit per org and replica; there is no bare unit. + machine.succeed("systemctl cat github-runner-test-orgs-yaxitech-1.service") + machine.succeed("systemctl cat github-runner-test-orgs-yaxitech-2.service") + machine.succeed("systemctl cat github-runner-test-orgs-another-1.service") + machine.fail("systemctl cat github-runner-test-orgs.service") + + # `orgs` with a shared GitHub App generates one App-authenticated unit per + # org, each with its per-org login derived from the attribute name. + machine.succeed("systemctl cat github-runner-test-orgs-app-yaxitech-1.service | grep -F unconfigure-github-app") + machine.succeed("systemctl cat github-runner-test-orgs-app-another-org-1.service | grep -F unconfigure-github-app") + machine.fail("systemctl list-unit-files | grep test-disabled") ''; }