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 [
202 "network-online.target"
203 ]
204 ++ optionals (wantsDocker) [
205 "docker.service"
206 ]
207 ++ optionals (wantsPodman) [
208 "podman.service"
209 ];
210 wantedBy = [
211 "multi-user.target"
212 ];
213 environment =
214 optionalAttrs (instance.token != null) {
215 TOKEN = "${instance.token}";
216 }
217 // optionalAttrs (wantsPodman) {
218 DOCKER_HOST = "unix:///run/podman/podman.sock";
219 }
220 // {
221 HOME = "/var/lib/gitea-runner/${name}";
222 };
223 path =
224 with pkgs;
225 [
226 coreutils
227 ]
228 ++ lib.optionals wantsHost instance.hostPackages;
229 serviceConfig =
230 {
231 DynamicUser = true;
232 User = "gitea-runner";
233 StateDirectory = "gitea-runner";
234 WorkingDirectory = "-/var/lib/gitea-runner/${name}";
235
236 # gitea-runner might fail when gitea is restarted during upgrade.
237 Restart = "on-failure";
238 RestartSec = 2;
239
240 ExecStartPre = [
241 (pkgs.writeShellScript "gitea-register-runner-${name}" ''
242 export INSTANCE_DIR="$STATE_DIRECTORY/${name}"
243 mkdir -vp "$INSTANCE_DIR"
244 cd "$INSTANCE_DIR"
245
246 # force reregistration on changed labels
247 export LABELS_FILE="$INSTANCE_DIR/.labels"
248 export LABELS_WANTED="$(echo ${escapeShellArg (concatStringsSep "\n" instance.labels)} | sort)"
249 export LABELS_CURRENT="$(cat $LABELS_FILE 2>/dev/null || echo 0)"
250
251 if [ ! -e "$INSTANCE_DIR/.runner" ] || [ "$LABELS_WANTED" != "$LABELS_CURRENT" ]; then
252 # remove existing registration file, so that changing the labels forces a re-registration
253 rm -v "$INSTANCE_DIR/.runner" || true
254
255 # perform the registration
256 ${cfg.package}/bin/act_runner register --no-interactive \
257 --instance ${escapeShellArg instance.url} \
258 --token "$TOKEN" \
259 --name ${escapeShellArg instance.name} \
260 --labels ${escapeShellArg (concatStringsSep "," instance.labels)} \
261 --config ${configFile}
262
263 # and write back the configured labels
264 echo "$LABELS_WANTED" > "$LABELS_FILE"
265 fi
266
267 '')
268 ];
269 ExecStart = "${cfg.package}/bin/act_runner daemon --config ${configFile}";
270 SupplementaryGroups =
271 optionals (wantsDocker) [
272 "docker"
273 ]
274 ++ optionals (wantsPodman) [
275 "podman"
276 ];
277 }
278 // optionalAttrs (instance.tokenFile != null) {
279 EnvironmentFile = instance.tokenFile;
280 };
281 };
282 in
283 mapAttrs' mkRunnerService cfg.instances;
284 };
285}