1
1
mirror of https://github.com/LnL7/nix-darwin.git synced 2024-07-15 00:10:30 +03:00

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
This commit is contained in:
Vincent Haupert 2024-02-28 09:40:25 +01:00
parent 0e6857fa1d
commit 06f5dab065
5 changed files with 323 additions and 167 deletions

View File

@ -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
];
}));
}

View File

@ -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";
};
};
}

View File

@ -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/<name>`:
State directory to store the runner registration credentials
* `/var/log/github-runners/<name>`:
The launchd service writes the stdout and stderr streams to this
directory.
* `/var/run/github-runners/<name>`:
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.
'';
};
};
}));
};

View File

@ -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" ];
};
}

View File

@ -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 "<string>github-runner</string>" ${config.out}/Library/LaunchDaemons/org.nixos.github-runner-a-runner.plist
grep "<string>_github-runner</string>" ${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