1{ config
2, lib
3, pkgs
4, utils
5, ...
6}:
7
8let
9 inherit (lib)
10 any
11 attrValues
12 concatStringsSep
13 escapeShellArg
14 hasInfix
15 hasSuffix
16 optionalAttrs
17 optionals
18 literalExpression
19 mapAttrs'
20 mkEnableOption
21 mkOption
22 mkPackageOption
23 mkIf
24 nameValuePair
25 types
26 ;
27
28 inherit (utils)
29 escapeSystemdPath
30 ;
31
32 cfg = config.services.gitea-actions-runner;
33
34 settingsFormat = pkgs.formats.yaml { };
35
36 # Check whether any runner instance label requires a container runtime
37 # Empty label strings result in the upstream defined defaultLabels, which require docker
38 # https://gitea.com/gitea/act_runner/src/tag/v0.1.5/internal/app/cmd/register.go#L93-L98
39 hasDockerScheme = instance:
40 instance.labels == [] || any (label: hasInfix ":docker:" label) instance.labels;
41 wantsContainerRuntime = any hasDockerScheme (attrValues cfg.instances);
42
43 hasHostScheme = instance: any (label: hasSuffix ":host" label) instance.labels;
44
45 # provide shorthands for whether container runtimes are enabled
46 hasDocker = config.virtualisation.docker.enable;
47 hasPodman = config.virtualisation.podman.enable;
48
49 tokenXorTokenFile = instance:
50 (instance.token == null && instance.tokenFile != null) ||
51 (instance.token != null && instance.tokenFile == null);
52in
53{
54 meta.maintainers = with lib.maintainers; [
55 hexa
56 ];
57
58 options.services.gitea-actions-runner = with types; {
59 package = mkPackageOption pkgs "gitea-actions-runner" { };
60
61 instances = mkOption {
62 default = {};
63 description = ''
64 Gitea Actions Runner instances.
65 '';
66 type = attrsOf (submodule {
67 options = {
68 enable = mkEnableOption "Gitea Actions Runner instance";
69
70 name = mkOption {
71 type = str;
72 example = literalExpression "config.networking.hostName";
73 description = ''
74 The name identifying the runner instance towards the Gitea/Forgejo instance.
75 '';
76 };
77
78 url = mkOption {
79 type = str;
80 example = "https://forge.example.com";
81 description = ''
82 Base URL of your Gitea/Forgejo instance.
83 '';
84 };
85
86 token = mkOption {
87 type = nullOr str;
88 default = null;
89 description = ''
90 Plain token to register at the configured Gitea/Forgejo instance.
91 '';
92 };
93
94 tokenFile = mkOption {
95 type = nullOr (either str path);
96 default = null;
97 description = ''
98 Path to an environment file, containing the `TOKEN` environment
99 variable, that holds a token to register at the configured
100 Gitea/Forgejo instance.
101 '';
102 };
103
104 labels = mkOption {
105 type = listOf str;
106 example = literalExpression ''
107 [
108 # provide a debian base with nodejs for actions
109 "debian-latest:docker://node:18-bullseye"
110 # fake the ubuntu name, because node provides no ubuntu builds
111 "ubuntu-latest:docker://node:18-bullseye"
112 # provide native execution on the host
113 #"native:host"
114 ]
115 '';
116 description = ''
117 Labels used to map jobs to their runtime environment. Changing these
118 labels currently requires a new registration token.
119
120 Many common actions require bash, git and nodejs, as well as a filesystem
121 that follows the filesystem hierarchy standard.
122 '';
123 };
124 settings = mkOption {
125 description = ''
126 Configuration for `act_runner daemon`.
127 See https://gitea.com/gitea/act_runner/src/branch/main/internal/pkg/config/config.example.yaml for an example configuration
128 '';
129
130 type = types.submodule {
131 freeformType = settingsFormat.type;
132 };
133
134 default = { };
135 };
136
137 hostPackages = mkOption {
138 type = listOf package;
139 default = with pkgs; [
140 bash
141 coreutils
142 curl
143 gawk
144 gitMinimal
145 gnused
146 nodejs
147 wget
148 ];
149 defaultText = literalExpression ''
150 with pkgs; [
151 bash
152 coreutils
153 curl
154 gawk
155 gitMinimal
156 gnused
157 nodejs
158 wget
159 ]
160 '';
161 description = ''
162 List of packages, that are available to actions, when the runner is configured
163 with a host execution label.
164 '';
165 };
166 };
167 });
168 };
169 };
170
171 config = mkIf (cfg.instances != {}) {
172 assertions = [ {
173 assertion = any tokenXorTokenFile (attrValues cfg.instances);
174 message = "Instances of gitea-actions-runner can have `token` or `tokenFile`, not both.";
175 } {
176 assertion = wantsContainerRuntime -> hasDocker || hasPodman;
177 message = "Label configuration on gitea-actions-runner instance requires either docker or podman.";
178 } ];
179
180 systemd.services = let
181 mkRunnerService = name: instance: let
182 wantsContainerRuntime = hasDockerScheme instance;
183 wantsHost = hasHostScheme instance;
184 wantsDocker = wantsContainerRuntime && config.virtualisation.docker.enable;
185 wantsPodman = wantsContainerRuntime && config.virtualisation.podman.enable;
186 configFile = settingsFormat.generate "config.yaml" instance.settings;
187 in
188 nameValuePair "gitea-runner-${escapeSystemdPath name}" {
189 inherit (instance) enable;
190 description = "Gitea Actions Runner";
191 wants = [ "network-online.target" ];
192 after = [
193 "network-online.target"
194 ] ++ optionals (wantsDocker) [
195 "docker.service"
196 ] ++ optionals (wantsPodman) [
197 "podman.service"
198 ];
199 wantedBy = [
200 "multi-user.target"
201 ];
202 environment = optionalAttrs (instance.token != null) {
203 TOKEN = "${instance.token}";
204 } // optionalAttrs (wantsPodman) {
205 DOCKER_HOST = "unix:///run/podman/podman.sock";
206 } // {
207 HOME = "/var/lib/gitea-runner/${name}";
208 };
209 path = with pkgs; [
210 coreutils
211 ] ++ lib.optionals wantsHost instance.hostPackages;
212 serviceConfig = {
213 DynamicUser = true;
214 User = "gitea-runner";
215 StateDirectory = "gitea-runner";
216 WorkingDirectory = "-/var/lib/gitea-runner/${name}";
217
218 # gitea-runner might fail when gitea is restarted during upgrade.
219 Restart = "on-failure";
220 RestartSec = 2;
221
222 ExecStartPre = [(pkgs.writeShellScript "gitea-register-runner-${name}" ''
223 export INSTANCE_DIR="$STATE_DIRECTORY/${name}"
224 mkdir -vp "$INSTANCE_DIR"
225 cd "$INSTANCE_DIR"
226
227 # force reregistration on changed labels
228 export LABELS_FILE="$INSTANCE_DIR/.labels"
229 export LABELS_WANTED="$(echo ${escapeShellArg (concatStringsSep "\n" instance.labels)} | sort)"
230 export LABELS_CURRENT="$(cat $LABELS_FILE 2>/dev/null || echo 0)"
231
232 if [ ! -e "$INSTANCE_DIR/.runner" ] || [ "$LABELS_WANTED" != "$LABELS_CURRENT" ]; then
233 # remove existing registration file, so that changing the labels forces a re-registration
234 rm -v "$INSTANCE_DIR/.runner" || true
235
236 # perform the registration
237 ${cfg.package}/bin/act_runner register --no-interactive \
238 --instance ${escapeShellArg instance.url} \
239 --token "$TOKEN" \
240 --name ${escapeShellArg instance.name} \
241 --labels ${escapeShellArg (concatStringsSep "," instance.labels)} \
242 --config ${configFile}
243
244 # and write back the configured labels
245 echo "$LABELS_WANTED" > "$LABELS_FILE"
246 fi
247
248 '')];
249 ExecStart = "${cfg.package}/bin/act_runner daemon --config ${configFile}";
250 SupplementaryGroups = optionals (wantsDocker) [
251 "docker"
252 ] ++ optionals (wantsPodman) [
253 "podman"
254 ];
255 } // optionalAttrs (instance.tokenFile != null) {
256 EnvironmentFile = instance.tokenFile;
257 };
258 };
259 in mapAttrs' mkRunnerService cfg.instances;
260 };
261}