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