ddns-go: 6.7.7 -> 6.8.0 (#373902)
[NixPkgs.git] / nixos / modules / services / continuous-integration / github-runner / service.nix
blobedf93417d20501fa7a006c63799c65d56a0e9a53
2   config,
3   lib,
4   pkgs,
5   ...
6 }:
8   config.assertions = lib.flatten (
9     lib.flip lib.mapAttrsToList config.services.github-runners (
10       name: cfg:
11       map (lib.mkIf cfg.enable) [
12         {
13           assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
14           message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
15         }
16         {
17           assertion = cfg.group == null || cfg.user != null;
18           message = ''`services.github-runners.${name}`: Setting `group` while leaving `user` unset runs the service as `root`. If this is really what you want, set `user = "root"` explicitly'';
19         }
20       ]
21     )
22   );
24   config.systemd.services =
25     let
26       enabledRunners = lib.filterAttrs (_: cfg: cfg.enable) config.services.github-runners;
27     in
28     (lib.flip lib.mapAttrs' enabledRunners (
29       name: cfg:
30       let
31         svcName = "github-runner-${name}";
32         systemdDir = "github-runner/${name}";
34         # %t: Runtime directory root (usually /run); see systemd.unit(5)
35         runtimeDir = "%t/${systemdDir}";
36         # %S: State directory root (usually /var/lib); see systemd.unit(5)
37         stateDir = "%S/${systemdDir}";
38         # %L: Log directory root (usually /var/log); see systemd.unit(5)
39         logsDir = "%L/${systemdDir}";
40         # Name of file stored in service state directory
41         currentConfigTokenFilename = ".current-token";
43         workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
44         # Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
45         package = cfg.package.override (
46           old: lib.optionalAttrs (lib.hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; }
47         );
48       in
49       lib.nameValuePair svcName {
50         description = "GitHub Actions runner";
52         wantedBy = [ "multi-user.target" ];
53         wants = [ "network-online.target" ];
54         after = [
55           "network.target"
56           "network-online.target"
57         ];
59         environment = {
60           HOME = workDir;
61           RUNNER_ROOT = stateDir;
62         } // cfg.extraEnvironment;
64         path =
65           (with pkgs; [
66             bashInteractive
67             coreutils
68             git
69             gnutar
70             gzip
71           ])
72           ++ [
73             config.nix.package
74           ]
75           ++ cfg.extraPackages;
77         serviceConfig = lib.mkMerge [
78           {
79             ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
81             # Does the following, sequentially:
82             # - If the module configuration or the token has changed, purge the state directory,
83             #   and create the current and the new token file with the contents of the configured
84             #   token. While both files have the same content, only the later is accessible by
85             #   the service user.
86             # - Configure the runner using the new token file. When finished, delete it.
87             # - Set up the directory structure by creating the necessary symlinks.
88             ExecStartPre =
89               let
90                 # Wrapper script which expects the full path of the state, working and logs
91                 # directory as arguments. Overrides the respective systemd variables to provide
92                 # unambiguous directory names. This becomes relevant, for example, if the
93                 # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
94                 # to contain more than one directory. This causes systemd to set the respective
95                 # environment variables with the path of all of the given directories, separated
96                 # by a colon.
97                 writeScript =
98                   name: lines:
99                   pkgs.writeShellScript "${svcName}-${name}.sh" ''
100                     set -euo pipefail
102                     STATE_DIRECTORY="$1"
103                     WORK_DIRECTORY="$2"
104                     LOGS_DIRECTORY="$3"
106                     ${lines}
107                   '';
108                 runnerRegistrationConfig = lib.getAttrs [
109                   "ephemeral"
110                   "extraLabels"
111                   "name"
112                   "noDefaultLabels"
113                   "runnerGroup"
114                   "tokenFile"
115                   "url"
116                   "workDir"
117                 ] cfg;
118                 newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
119                 currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
120                 newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
121                 currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
123                 runnerCredFiles = [
124                   ".credentials"
125                   ".credentials_rsaparams"
126                   ".runner"
127                 ];
128                 unconfigureRunner = writeScript "unconfigure" ''
129                   copy_tokens() {
130                     # Copy the configured token file to the state dir and allow the service user to read the file
131                     install --mode=666 ${lib.escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
132                     # Also copy current file to allow for a diff on the next start
133                     install --mode=600 ${lib.escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
134                   }
135                   clean_state() {
136                     find "$STATE_DIRECTORY/" -mindepth 1 -delete
137                     copy_tokens
138                   }
139                   diff_config() {
140                     changed=0
141                     # Check for module config changes
142                     [[ -f "${currentConfigPath}" ]] \
143                       && ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
144                       || changed=1
145                     # Also check the content of the token file
146                     [[ -f "${currentConfigTokenPath}" ]] \
147                       && ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${lib.escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
148                       || changed=1
149                     # If the config has changed, remove old state and copy tokens
150                     if [[ "$changed" -eq 1 ]]; then
151                       echo "Config has changed, removing old runner state."
152                       echo "The old runner will still appear in the GitHub Actions UI." \
153                            "You have to remove it manually."
154                       clean_state
155                     fi
156                   }
157                   if [[ "${lib.optionalString cfg.ephemeral "1"}" ]]; then
158                     # In ephemeral mode, we always want to start with a clean state
159                     clean_state
160                   elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
161                     # There are state files from a previous run; diff them to decide if we need a new registration
162                     diff_config
163                   else
164                     # The state directory is entirely empty which indicates a first start
165                     copy_tokens
166                   fi
167                   # Always clean workDir
168                   find -H "$WORK_DIRECTORY" -mindepth 1 -delete
169                 '';
170                 configureRunner =
171                   writeScript "configure" # bash
172                     ''
173                       if [[ -e "${newConfigTokenPath}" ]]; then
174                         echo "Configuring GitHub Actions Runner"
175                         # shellcheck disable=SC2054  # don't complain about commas in --labels
176                         args=(
177                           --unattended
178                           --disableupdate
179                           --work "$WORK_DIRECTORY"
180                           --url ${lib.escapeShellArg cfg.url}
181                           --labels ${lib.escapeShellArg (lib.concatStringsSep "," cfg.extraLabels)}
182                           ${lib.optionalString (cfg.name != null) "--name ${lib.escapeShellArg cfg.name}"}
183                           ${lib.optionalString cfg.replace "--replace"}
184                           ${lib.optionalString (
185                             cfg.runnerGroup != null
186                           ) "--runnergroup ${lib.escapeShellArg cfg.runnerGroup}"}
187                           ${lib.optionalString cfg.ephemeral "--ephemeral"}
188                           ${lib.optionalString cfg.noDefaultLabels "--no-default-labels"}
189                         )
190                         # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
191                         # if it is not a PAT, we assume it contains a registration token and use the --token option
192                         token=$(<"${newConfigTokenPath}")
193                         if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
194                           args+=(--pat "$token")
195                         else
196                           args+=(--token "$token")
197                         fi
198                         ${package}/bin/Runner.Listener configure "''${args[@]}"
199                         # Move the automatically created _diag dir to the logs dir
200                         mkdir -p  "$STATE_DIRECTORY/_diag"
201                         cp    -r  "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
202                         rm    -rf "$STATE_DIRECTORY/_diag/"
203                         # Cleanup token from config
204                         rm "${newConfigTokenPath}"
205                         # Symlink to new config
206                         ln -s '${newConfigPath}' "${currentConfigPath}"
207                       fi
208                     '';
209                 setupWorkDir = writeScript "setup-work-dirs" ''
210                   # Link _diag dir
211                   ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
213                   # Link the runner credentials to the work dir
214                   ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
215                 '';
216               in
217               map
218                 (
219                   x:
220                   "${x} ${
221                     lib.escapeShellArgs [
222                       stateDir
223                       workDir
224                       logsDir
225                     ]
226                   }"
227                 )
228                 [
229                   "+${unconfigureRunner}" # runs as root
230                   configureRunner
231                   setupWorkDir
232                 ];
234             # If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
235             # to trigger a fresh registration.
236             Restart = if cfg.ephemeral then "on-success" else "no";
237             # If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
238             # https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
239             RestartForceExitStatus = [ 2 ];
241             # Contains _diag
242             LogsDirectory = [ systemdDir ];
243             # Default RUNNER_ROOT which contains ephemeral Runner data
244             RuntimeDirectory = [ systemdDir ];
245             # Home of persistent runner data, e.g., credentials
246             StateDirectory = [ systemdDir ];
247             StateDirectoryMode = "0700";
248             WorkingDirectory = workDir;
250             InaccessiblePaths = [
251               # Token file path given in the configuration, if visible to the service
252               "-${cfg.tokenFile}"
253               # Token file in the state directory
254               "${stateDir}/${currentConfigTokenFilename}"
255             ];
257             KillSignal = "SIGINT";
259             # Hardening (may overlap with DynamicUser=)
260             # The following options are only for optimizing:
261             # systemd-analyze security github-runner
262             AmbientCapabilities = lib.mkBefore [ "" ];
263             CapabilityBoundingSet = lib.mkBefore [ "" ];
264             # ProtectClock= adds DeviceAllow=char-rtc r
265             DeviceAllow = lib.mkBefore [ "" ];
266             NoNewPrivileges = lib.mkDefault true;
267             PrivateDevices = lib.mkDefault true;
268             PrivateMounts = lib.mkDefault true;
269             PrivateTmp = lib.mkDefault true;
270             PrivateUsers = lib.mkDefault true;
271             ProtectClock = lib.mkDefault true;
272             ProtectControlGroups = lib.mkDefault true;
273             ProtectHome = lib.mkDefault true;
274             ProtectHostname = lib.mkDefault true;
275             ProtectKernelLogs = lib.mkDefault true;
276             ProtectKernelModules = lib.mkDefault true;
277             ProtectKernelTunables = lib.mkDefault true;
278             ProtectSystem = lib.mkDefault "strict";
279             RemoveIPC = lib.mkDefault true;
280             RestrictNamespaces = lib.mkDefault true;
281             RestrictRealtime = lib.mkDefault true;
282             RestrictSUIDSGID = lib.mkDefault true;
283             UMask = lib.mkDefault "0066";
284             ProtectProc = lib.mkDefault "invisible";
285             SystemCallFilter = lib.mkBefore [
286               "~@clock"
287               "~@cpu-emulation"
288               "~@module"
289               "~@mount"
290               "~@obsolete"
291               "~@raw-io"
292               "~@reboot"
293               "~capset"
294               "~setdomainname"
295               "~sethostname"
296             ];
297             RestrictAddressFamilies = lib.mkBefore [
298               "AF_INET"
299               "AF_INET6"
300               "AF_UNIX"
301               "AF_NETLINK"
302             ];
304             BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
306             # Needs network access
307             PrivateNetwork = lib.mkDefault false;
308             # Cannot be true due to Node
309             MemoryDenyWriteExecute = lib.mkDefault false;
311             # The more restrictive "pid" option makes `nix` commands in CI emit
312             # "GC Warning: Couldn't read /proc/stat"
313             # You may want to set this to "pid" if not using `nix` commands
314             ProcSubset = lib.mkDefault "all";
315             # Coverage programs for compiled code such as `cargo-tarpaulin` disable
316             # ASLR (address space layout randomization) which requires the
317             # `personality` syscall
318             # You may want to set this to `true` if not using coverage tooling on
319             # compiled code
320             LockPersonality = lib.mkDefault false;
322             DynamicUser = lib.mkDefault true;
323           }
324           (lib.mkIf (cfg.user != null) {
325             DynamicUser = false;
326             User = cfg.user;
327           })
328           (lib.mkIf (cfg.group != null) {
329             DynamicUser = false;
330             Group = cfg.group;
331           })
332           cfg.serviceOverrides
333         ];
334       }
335     ));