···
5
-
, cfg ? config.services.github-runner
8
-
, systemdDir ? "${svcName}/${cfg.name}"
9
-
# %t: Runtime directory root (usually /run); see systemd.unit(5)
10
-
, runtimeDir ? "%t/${systemdDir}"
11
-
# %S: State directory root (usually /var/lib); see systemd.unit(5)
12
-
, stateDir ? "%S/${systemdDir}"
13
-
# %L: Log directory root (usually /var/log); see systemd.unit(5)
14
-
, logsDir ? "%L/${systemdDir}"
15
-
# Name of file stored in service state directory
16
-
, currentConfigTokenFilename ? ".current-token"
9
+
config.assertions = flatten (
10
+
flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [
12
+
assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
13
+
message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
24
-
workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
25
-
# Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
26
-
package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; });
29
-
description = "GitHub Actions runner";
18
+
config.systemd.services = flip mapAttrs' config.services.github-runners (name: cfg:
20
+
svcName = "github-runner-${name}";
21
+
systemdDir = "github-runner/${name}";
23
+
# %t: Runtime directory root (usually /run); see systemd.unit(5)
24
+
runtimeDir = "%t/${systemdDir}";
25
+
# %S: State directory root (usually /var/lib); see systemd.unit(5)
26
+
stateDir = "%S/${systemdDir}";
27
+
# %L: Log directory root (usually /var/log); see systemd.unit(5)
28
+
logsDir = "%L/${systemdDir}";
29
+
# Name of file stored in service state directory
30
+
currentConfigTokenFilename = ".current-token";
31
-
wantedBy = [ "multi-user.target" ];
32
-
wants = [ "network-online.target" ];
33
-
after = [ "network.target" "network-online.target" ];
32
+
workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
33
+
# Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
34
+
package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; });
36
+
nameValuePair svcName {
37
+
description = "GitHub Actions runner";
37
-
RUNNER_ROOT = stateDir;
38
-
} // cfg.extraEnvironment;
39
+
wantedBy = [ "multi-user.target" ];
40
+
wants = [ "network-online.target" ];
41
+
after = [ "network.target" "network-online.target" ];
40
-
path = (with pkgs; [
48
-
] ++ cfg.extraPackages;
45
+
RUNNER_ROOT = stateDir;
46
+
} // cfg.extraEnvironment;
48
+
path = (with pkgs; [
56
+
] ++ cfg.extraPackages;
50
-
serviceConfig = mkMerge [
52
-
ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
58
+
serviceConfig = mkMerge [
60
+
ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
54
-
# Does the following, sequentially:
55
-
# - If the module configuration or the token has changed, purge the state directory,
56
-
# and create the current and the new token file with the contents of the configured
57
-
# token. While both files have the same content, only the later is accessible by
59
-
# - Configure the runner using the new token file. When finished, delete it.
60
-
# - Set up the directory structure by creating the necessary symlinks.
63
-
# Wrapper script which expects the full path of the state, working and logs
64
-
# directory as arguments. Overrides the respective systemd variables to provide
65
-
# unambiguous directory names. This becomes relevant, for example, if the
66
-
# caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
67
-
# to contain more than one directory. This causes systemd to set the respective
68
-
# environment variables with the path of all of the given directories, separated
70
-
writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
62
+
# Does the following, sequentially:
63
+
# - If the module configuration or the token has changed, purge the state directory,
64
+
# and create the current and the new token file with the contents of the configured
65
+
# token. While both files have the same content, only the later is accessible by
67
+
# - Configure the runner using the new token file. When finished, delete it.
68
+
# - Set up the directory structure by creating the necessary symlinks.
71
+
# Wrapper script which expects the full path of the state, working and logs
72
+
# directory as arguments. Overrides the respective systemd variables to provide
73
+
# unambiguous directory names. This becomes relevant, for example, if the
74
+
# caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
75
+
# to contain more than one directory. This causes systemd to set the respective
76
+
# environment variables with the path of all of the given directories, separated
78
+
writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
73
-
STATE_DIRECTORY="$1"
81
+
STATE_DIRECTORY="$1"
79
-
runnerRegistrationConfig = getAttrs [
89
-
newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
90
-
currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
91
-
newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
92
-
currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
87
+
runnerRegistrationConfig = getAttrs [
98
+
newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
99
+
currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
100
+
newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
101
+
currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
96
-
".credentials_rsaparams"
99
-
unconfigureRunner = writeScript "unconfigure" ''
101
-
# Copy the configured token file to the state dir and allow the service user to read the file
102
-
install --mode=666 ${escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
103
-
# Also copy current file to allow for a diff on the next start
104
-
install --mode=600 ${escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
107
-
find "$STATE_DIRECTORY/" -mindepth 1 -delete
112
-
# Check for module config changes
113
-
[[ -f "${currentConfigPath}" ]] \
114
-
&& ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
116
-
# Also check the content of the token file
117
-
[[ -f "${currentConfigTokenPath}" ]] \
118
-
&& ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
120
-
# If the config has changed, remove old state and copy tokens
121
-
if [[ "$changed" -eq 1 ]]; then
122
-
echo "Config has changed, removing old runner state."
123
-
echo "The old runner will still appear in the GitHub Actions UI." \
124
-
"You have to remove it manually."
128
-
if [[ "${optionalString cfg.ephemeral "1"}" ]]; then
129
-
# In ephemeral mode, we always want to start with a clean state
131
-
elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
132
-
# There are state files from a previous run; diff them to decide if we need a new registration
135
-
# The state directory is entirely empty which indicates a first start
138
-
# Always clean workDir
139
-
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
141
-
configureRunner = writeScript "configure" ''
142
-
if [[ -e "${newConfigTokenPath}" ]]; then
143
-
echo "Configuring GitHub Actions Runner"
147
-
--work "$WORK_DIRECTORY"
148
-
--url ${escapeShellArg cfg.url}
149
-
--labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)}
150
-
--name ${escapeShellArg cfg.name}
151
-
${optionalString cfg.replace "--replace"}
152
-
${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
153
-
${optionalString cfg.ephemeral "--ephemeral"}
154
-
${optionalString cfg.noDefaultLabels "--no-default-labels"}
156
-
# If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
157
-
# if it is not a PAT, we assume it contains a registration token and use the --token option
158
-
token=$(<"${newConfigTokenPath}")
159
-
if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
160
-
args+=(--pat "$token")
162
-
args+=(--token "$token")
164
-
${package}/bin/Runner.Listener configure "''${args[@]}"
165
-
# Move the automatically created _diag dir to the logs dir
166
-
mkdir -p "$STATE_DIRECTORY/_diag"
167
-
cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
168
-
rm -rf "$STATE_DIRECTORY/_diag/"
169
-
# Cleanup token from config
170
-
rm "${newConfigTokenPath}"
171
-
# Symlink to new config
172
-
ln -s '${newConfigPath}' "${currentConfigPath}"
175
-
setupWorkDir = writeScript "setup-work-dirs" ''
177
-
ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
103
+
runnerCredFiles = [
105
+
".credentials_rsaparams"
108
+
unconfigureRunner = writeScript "unconfigure" ''
110
+
# Copy the configured token file to the state dir and allow the service user to read the file
111
+
install --mode=666 ${escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
112
+
# Also copy current file to allow for a diff on the next start
113
+
install --mode=600 ${escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
116
+
find "$STATE_DIRECTORY/" -mindepth 1 -delete
121
+
# Check for module config changes
122
+
[[ -f "${currentConfigPath}" ]] \
123
+
&& ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
125
+
# Also check the content of the token file
126
+
[[ -f "${currentConfigTokenPath}" ]] \
127
+
&& ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
129
+
# If the config has changed, remove old state and copy tokens
130
+
if [[ "$changed" -eq 1 ]]; then
131
+
echo "Config has changed, removing old runner state."
132
+
echo "The old runner will still appear in the GitHub Actions UI." \
133
+
"You have to remove it manually."
137
+
if [[ "${optionalString cfg.ephemeral "1"}" ]]; then
138
+
# In ephemeral mode, we always want to start with a clean state
140
+
elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
141
+
# There are state files from a previous run; diff them to decide if we need a new registration
144
+
# The state directory is entirely empty which indicates a first start
147
+
# Always clean workDir
148
+
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
150
+
configureRunner = writeScript "configure" ''
151
+
if [[ -e "${newConfigTokenPath}" ]]; then
152
+
echo "Configuring GitHub Actions Runner"
156
+
--work "$WORK_DIRECTORY"
157
+
--url ${escapeShellArg cfg.url}
158
+
--labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)}
159
+
${optionalString (cfg.name != null ) "--name ${escapeShellArg cfg.name}"}
160
+
${optionalString cfg.replace "--replace"}
161
+
${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
162
+
${optionalString cfg.ephemeral "--ephemeral"}
163
+
${optionalString cfg.noDefaultLabels "--no-default-labels"}
165
+
# If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
166
+
# if it is not a PAT, we assume it contains a registration token and use the --token option
167
+
token=$(<"${newConfigTokenPath}")
168
+
if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
169
+
args+=(--pat "$token")
171
+
args+=(--token "$token")
173
+
${package}/bin/Runner.Listener configure "''${args[@]}"
174
+
# Move the automatically created _diag dir to the logs dir
175
+
mkdir -p "$STATE_DIRECTORY/_diag"
176
+
cp -r "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
177
+
rm -rf "$STATE_DIRECTORY/_diag/"
178
+
# Cleanup token from config
179
+
rm "${newConfigTokenPath}"
180
+
# Symlink to new config
181
+
ln -s '${newConfigPath}' "${currentConfigPath}"
184
+
setupWorkDir = writeScript "setup-work-dirs" ''
186
+
ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
179
-
# Link the runner credentials to the work dir
180
-
ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
183
-
map (x: "${x} ${escapeShellArgs [ stateDir workDir logsDir ]}") [
184
-
"+${unconfigureRunner}" # runs as root
188
+
# Link the runner credentials to the work dir
189
+
ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
192
+
map (x: "${x} ${escapeShellArgs [ stateDir workDir logsDir ]}") [
193
+
"+${unconfigureRunner}" # runs as root
189
-
# If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
190
-
# to trigger a fresh registration.
191
-
Restart = if cfg.ephemeral then "on-success" else "no";
192
-
# If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
193
-
# https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
194
-
RestartForceExitStatus = [ 2 ];
198
+
# If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
199
+
# to trigger a fresh registration.
200
+
Restart = if cfg.ephemeral then "on-success" else "no";
201
+
# If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
202
+
# https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
203
+
RestartForceExitStatus = [ 2 ];
197
-
LogsDirectory = [ systemdDir ];
198
-
# Default RUNNER_ROOT which contains ephemeral Runner data
199
-
RuntimeDirectory = [ systemdDir ];
200
-
# Home of persistent runner data, e.g., credentials
201
-
StateDirectory = [ systemdDir ];
202
-
StateDirectoryMode = "0700";
203
-
WorkingDirectory = workDir;
206
+
LogsDirectory = [ systemdDir ];
207
+
# Default RUNNER_ROOT which contains ephemeral Runner data
208
+
RuntimeDirectory = [ systemdDir ];
209
+
# Home of persistent runner data, e.g., credentials
210
+
StateDirectory = [ systemdDir ];
211
+
StateDirectoryMode = "0700";
212
+
WorkingDirectory = workDir;
205
-
InaccessiblePaths = [
206
-
# Token file path given in the configuration, if visible to the service
207
-
"-${cfg.tokenFile}"
208
-
# Token file in the state directory
209
-
"${stateDir}/${currentConfigTokenFilename}"
214
+
InaccessiblePaths = [
215
+
# Token file path given in the configuration, if visible to the service
216
+
"-${cfg.tokenFile}"
217
+
# Token file in the state directory
218
+
"${stateDir}/${currentConfigTokenFilename}"
212
-
KillSignal = "SIGINT";
221
+
KillSignal = "SIGINT";
214
-
# Hardening (may overlap with DynamicUser=)
215
-
# The following options are only for optimizing:
216
-
# systemd-analyze security github-runner
217
-
AmbientCapabilities = mkBefore [ "" ];
218
-
CapabilityBoundingSet = mkBefore [ "" ];
219
-
# ProtectClock= adds DeviceAllow=char-rtc r
220
-
DeviceAllow = mkBefore [ "" ];
221
-
NoNewPrivileges = mkDefault true;
222
-
PrivateDevices = mkDefault true;
223
-
PrivateMounts = mkDefault true;
224
-
PrivateTmp = mkDefault true;
225
-
PrivateUsers = mkDefault true;
226
-
ProtectClock = mkDefault true;
227
-
ProtectControlGroups = mkDefault true;
228
-
ProtectHome = mkDefault true;
229
-
ProtectHostname = mkDefault true;
230
-
ProtectKernelLogs = mkDefault true;
231
-
ProtectKernelModules = mkDefault true;
232
-
ProtectKernelTunables = mkDefault true;
233
-
ProtectSystem = mkDefault "strict";
234
-
RemoveIPC = mkDefault true;
235
-
RestrictNamespaces = mkDefault true;
236
-
RestrictRealtime = mkDefault true;
237
-
RestrictSUIDSGID = mkDefault true;
238
-
UMask = mkDefault "0066";
239
-
ProtectProc = mkDefault "invisible";
240
-
SystemCallFilter = mkBefore [
252
-
RestrictAddressFamilies = mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
223
+
# Hardening (may overlap with DynamicUser=)
224
+
# The following options are only for optimizing:
225
+
# systemd-analyze security github-runner
226
+
AmbientCapabilities = mkBefore [ "" ];
227
+
CapabilityBoundingSet = mkBefore [ "" ];
228
+
# ProtectClock= adds DeviceAllow=char-rtc r
229
+
DeviceAllow = mkBefore [ "" ];
230
+
NoNewPrivileges = mkDefault true;
231
+
PrivateDevices = mkDefault true;
232
+
PrivateMounts = mkDefault true;
233
+
PrivateTmp = mkDefault true;
234
+
PrivateUsers = mkDefault true;
235
+
ProtectClock = mkDefault true;
236
+
ProtectControlGroups = mkDefault true;
237
+
ProtectHome = mkDefault true;
238
+
ProtectHostname = mkDefault true;
239
+
ProtectKernelLogs = mkDefault true;
240
+
ProtectKernelModules = mkDefault true;
241
+
ProtectKernelTunables = mkDefault true;
242
+
ProtectSystem = mkDefault "strict";
243
+
RemoveIPC = mkDefault true;
244
+
RestrictNamespaces = mkDefault true;
245
+
RestrictRealtime = mkDefault true;
246
+
RestrictSUIDSGID = mkDefault true;
247
+
UMask = mkDefault "0066";
248
+
ProtectProc = mkDefault "invisible";
249
+
SystemCallFilter = mkBefore [
261
+
RestrictAddressFamilies = mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
254
-
BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
263
+
BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
256
-
# Needs network access
257
-
PrivateNetwork = mkDefault false;
258
-
# Cannot be true due to Node
259
-
MemoryDenyWriteExecute = mkDefault false;
265
+
# Needs network access
266
+
PrivateNetwork = mkDefault false;
267
+
# Cannot be true due to Node
268
+
MemoryDenyWriteExecute = mkDefault false;
261
-
# The more restrictive "pid" option makes `nix` commands in CI emit
262
-
# "GC Warning: Couldn't read /proc/stat"
263
-
# You may want to set this to "pid" if not using `nix` commands
264
-
ProcSubset = mkDefault "all";
265
-
# Coverage programs for compiled code such as `cargo-tarpaulin` disable
266
-
# ASLR (address space layout randomization) which requires the
267
-
# `personality` syscall
268
-
# You may want to set this to `true` if not using coverage tooling on
270
-
LockPersonality = mkDefault false;
270
+
# The more restrictive "pid" option makes `nix` commands in CI emit
271
+
# "GC Warning: Couldn't read /proc/stat"
272
+
# You may want to set this to "pid" if not using `nix` commands
273
+
ProcSubset = mkDefault "all";
274
+
# Coverage programs for compiled code such as `cargo-tarpaulin` disable
275
+
# ASLR (address space layout randomization) which requires the
276
+
# `personality` syscall
277
+
# You may want to set this to `true` if not using coverage tooling on
279
+
LockPersonality = mkDefault false;
272
-
# Note that this has some interactions with the User setting; so you may
273
-
# want to consult the systemd docs if using both.
274
-
DynamicUser = mkDefault true;
281
+
# Note that this has some interactions with the User setting; so you may
282
+
# want to consult the systemd docs if using both.
283
+
DynamicUser = mkDefault true;
285
+
(mkIf (cfg.user != null) { User = cfg.user; })
286
+
cfg.serviceOverrides
276
-
(mkIf (cfg.user != null) { User = cfg.user; })
277
-
cfg.serviceOverrides