mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-08-26 02:05:02 +00:00
nixos/github-runners: GitHub App authentication and multi-org runners (#533377)
This commit is contained in:
@@ -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
|
||||
@@ -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.<name>.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.<name>.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-<name>` for `count == 1` (unchanged) and
|
||||
`github-runner-<name>-<n>` 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.<name>.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-<name>-<org>-<n>`, where `<org>` is the
|
||||
attribute name and `<n>` 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-<name>-<org>-<n>`
|
||||
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 = ''
|
||||
|
||||
@@ -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}\"}"
|
||||
@@ -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}"
|
||||
@@ -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>` name
|
||||
# for backwards compatibility; `count > 1` suffixes `-<n>`.
|
||||
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";
|
||||
|
||||
|
||||
@@ -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-<name>-<n>`.
|
||||
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-<name>-<org>-<n>`.
|
||||
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
|
||||
# `-<n>` 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")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user