silx: 2.1.1 -> 2.1.2 (#361612)
[NixPkgs.git] / nixos / modules / services / misc / sourcehut / service.nix
blob3507a49ea13a888496adabad0977d6c1524d4e70
1 srv:
2 { configIniOfService
3 , srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s").
4 , iniKey ? "${srv}.sr.ht"
5 , webhooks ? false
6 , extraTimers ? { }
7 , mainService ? { }
8 , extraServices ? { }
9 , extraConfig ? { }
10 , port
12 { config, lib, pkgs, ... }:
14 let
15   inherit (lib) types;
16   inherit (lib.attrsets) mapAttrs optionalAttrs;
17   inherit (lib.lists) optional;
18   inherit (lib.modules) mkBefore mkDefault mkForce mkIf mkMerge;
19   inherit (lib.options) mkEnableOption mkOption;
20   inherit (lib.strings) concatStringsSep hasSuffix optionalString;
21   inherit (config.services) postgresql;
22   redis = config.services.redis.servers."sourcehut-${srvsrht}";
23   inherit (config.users) users;
24   cfg = config.services.sourcehut;
25   configIni = configIniOfService srv;
26   srvCfg = cfg.${srv};
27   baseService = serviceName: { allowStripe ? false }: extraService:
28     let
29       runDir = "/run/sourcehut/${serviceName}";
30       rootDir = "/run/sourcehut/chroots/${serviceName}";
31     in
32     mkMerge [
33       extraService
34       {
35         after = [ "network.target" ] ++
36           optional cfg.postgresql.enable "postgresql.service" ++
37           optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
38         requires =
39           optional cfg.postgresql.enable "postgresql.service" ++
40           optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
41         path = [ pkgs.gawk ];
42         environment.HOME = runDir;
43         serviceConfig = {
44           User = mkDefault srvCfg.user;
45           Group = mkDefault srvCfg.group;
46           RuntimeDirectory = [
47             "sourcehut/${serviceName}"
48             # Used by *srht-keys which reads ../config.ini
49             "sourcehut/${serviceName}/subdir"
50             "sourcehut/chroots/${serviceName}"
51           ];
52           RuntimeDirectoryMode = "2750";
53           # No need for the chroot path once inside the chroot
54           InaccessiblePaths = [ "-+${rootDir}" ];
55           # g+rx is for group members (eg. fcgiwrap or nginx)
56           # to read Git/Mercurial repositories, buildlogs, etc.
57           # o+x is for intermediate directories created by BindPaths= and like,
58           # as they're owned by root:root.
59           UMask = "0026";
60           RootDirectory = rootDir;
61           RootDirectoryStartOnly = true;
62           PrivateTmp = true;
63           MountAPIVFS = true;
64           # config.ini is looked up in there, before /etc/srht/config.ini
65           # Note that it fails to be set in ExecStartPre=
66           WorkingDirectory = mkDefault ("-" + runDir);
67           BindReadOnlyPaths = [
68             builtins.storeDir
69             "/etc"
70             "/run/booted-system"
71             "/run/current-system"
72             "/run/systemd"
73           ] ++
74           optional cfg.postgresql.enable "/run/postgresql" ++
75           optional cfg.redis.enable "/run/redis-sourcehut-${srvsrht}";
76           # LoadCredential= are unfortunately not available in ExecStartPre=
77           # Hence this one is run as root (the +) with RootDirectoryStartOnly=
78           # to reach credentials wherever they are.
79           # Note that each systemd service gets its own ${runDir}/config.ini file.
80           ExecStartPre = mkBefore [
81             ("+" + pkgs.writeShellScript "${serviceName}-credentials" ''
82               set -x
83               # Replace values beginning with a '<' by the content of the file whose name is after.
84               gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} |
85               ${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"}
86               install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini
87             '')
88           ];
89           # The following options are only for optimizing:
90           # systemd-analyze security
91           AmbientCapabilities = "";
92           CapabilityBoundingSet = "";
93           # ProtectClock= adds DeviceAllow=char-rtc r
94           DeviceAllow = "";
95           LockPersonality = true;
96           MemoryDenyWriteExecute = true;
97           NoNewPrivileges = true;
98           PrivateDevices = true;
99           PrivateMounts = true;
100           PrivateNetwork = mkDefault false;
101           PrivateUsers = true;
102           ProcSubset = "pid";
103           ProtectClock = true;
104           ProtectControlGroups = true;
105           ProtectHome = true;
106           ProtectHostname = true;
107           ProtectKernelLogs = true;
108           ProtectKernelModules = true;
109           ProtectKernelTunables = true;
110           ProtectProc = "invisible";
111           ProtectSystem = "strict";
112           RemoveIPC = true;
113           RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
114           RestrictNamespaces = true;
115           RestrictRealtime = true;
116           RestrictSUIDSGID = true;
117           #SocketBindAllow = [ "tcp:${toString srvCfg.port}" "tcp:${toString srvCfg.prometheusPort}" ];
118           #SocketBindDeny = "any";
119           SystemCallFilter = [
120             "@system-service"
121             "~@aio"
122             "~@keyring"
123             "~@memlock"
124             "~@privileged"
125             "~@timer"
126             "@chown"
127             "@setuid"
128           ];
129           SystemCallArchitectures = "native";
130         };
131       }
132     ];
135   options.services.sourcehut.${srv} = {
136     enable = mkEnableOption "${srv} service";
138     user = mkOption {
139       type = types.str;
140       default = srvsrht;
141       description = ''
142         User for ${srv}.sr.ht.
143       '';
144     };
146     group = mkOption {
147       type = types.str;
148       default = srvsrht;
149       description = ''
150         Group for ${srv}.sr.ht.
151         Membership grants access to the Git/Mercurial repositories by default,
152         but not to the config.ini file (where secrets are).
153       '';
154     };
156     port = mkOption {
157       type = types.port;
158       default = port;
159       description = ''
160         Port on which the "${srv}" backend should listen.
161       '';
162     };
164     redis = {
165       host = mkOption {
166         type = types.str;
167         default = "unix:///run/redis-sourcehut-${srvsrht}/redis.sock?db=0";
168         example = "redis://shared.wireguard:6379/0";
169         description = ''
170           The redis host URL. This is used for caching and temporary storage, and must
171           be shared between nodes (e.g. git1.sr.ht and git2.sr.ht), but need not be
172           shared between services. It may be shared between services, however, with no
173           ill effect, if this better suits your infrastructure.
174         '';
175       };
176     };
178     postgresql = {
179       database = mkOption {
180         type = types.str;
181         default = "${srv}.sr.ht";
182         description = ''
183           PostgreSQL database name for the ${srv}.sr.ht service,
184           used if [](#opt-services.sourcehut.postgresql.enable) is `true`.
185         '';
186       };
187     };
189     gunicorn = {
190       extraArgs = mkOption {
191         type = with types; listOf str;
192         default = [ "--timeout 120" "--workers 1" "--log-level=info" ];
193         description = "Extra arguments passed to Gunicorn.";
194       };
195     };
196   } // optionalAttrs webhooks {
197     webhooks = {
198       extraArgs = mkOption {
199         type = with types; listOf str;
200         default = [ "--loglevel DEBUG" "--pool eventlet" "--without-heartbeat" ];
201         description = "Extra arguments passed to the Celery responsible for webhooks.";
202       };
203       celeryConfig = mkOption {
204         type = types.lines;
205         default = "";
206         description = "Content of the `celeryconfig.py` used by the Celery responsible for webhooks.";
207       };
208     };
209   };
211   config = lib.mkIf (cfg.enable && srvCfg.enable) (mkMerge [
212     extraConfig
213     {
214       users = {
215         users = {
216           "${srvCfg.user}" = {
217             isSystemUser = true;
218             group = mkDefault srvCfg.group;
219             description = mkDefault "sourcehut user for ${srv}.sr.ht";
220           };
221         };
222         groups = {
223           "${srvCfg.group}" = { };
224         } // optionalAttrs
225           (cfg.postgresql.enable
226             && hasSuffix "0" (postgresql.settings.unix_socket_permissions or ""))
227           {
228             "postgres".members = [ srvCfg.user ];
229           } // optionalAttrs
230           (cfg.redis.enable
231             && hasSuffix "0" (redis.settings.unixsocketperm or ""))
232           {
233             "redis-sourcehut-${srvsrht}".members = [ srvCfg.user ];
234           };
235       };
237       services.nginx = mkIf cfg.nginx.enable {
238         virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = mkMerge [{
239           forceSSL = mkDefault true;
240           locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}";
241           locations."/static" = {
242             root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}";
243             extraConfig = mkDefault ''
244               expires 30d;
245             '';
246           };
247           locations."/query" = mkIf (cfg.settings.${iniKey} ? api-origin) {
248             proxyPass = cfg.settings.${iniKey}.api-origin;
249             extraConfig = ''
250               add_header 'Access-Control-Allow-Origin' '*';
251               add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
252               add_header 'Access-Control-Allow-Headers' 'User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
254               if ($request_method = 'OPTIONS') {
255                 add_header 'Access-Control-Max-Age' 1728000;
256                 add_header 'Content-Type' 'text/plain; charset=utf-8';
257                 add_header 'Content-Length' 0;
258                 return 204;
259               }
261               add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
262             '';
263           };
264         }
265           cfg.nginx.virtualHost];
266       };
268       services.postgresql = mkIf cfg.postgresql.enable {
269         authentication = ''
270           local ${srvCfg.postgresql.database} ${srvCfg.user} trust
271         '';
272         ensureDatabases = [ srvCfg.postgresql.database ];
273         ensureUsers = map
274           (name: {
275             inherit name;
276             # We don't use it because we have a special default database name with dots.
277             # TODO(for maintainers of sourcehut): migrate away from custom preStart script.
278             ensureDBOwnership = false;
279           }) [ srvCfg.user ];
280       };
283       services.sourcehut.settings = mkMerge [
284         {
285           "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
286         }
288         (mkIf cfg.postgresql.enable {
289           "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
290         })
291       ];
293       services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
294         enable = true;
295         databases = 3;
296         syslog = true;
297         # TODO: set a more informed value
298         save = mkDefault [ [ 1800 10 ] [ 300 100 ] ];
299         settings = {
300           # TODO: set a more informed value
301           maxmemory = "128MB";
302           maxmemory-policy = "volatile-ttl";
303         };
304       };
306       systemd.services = mkMerge [
307         {
308           "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
309             {
310               description = "sourcehut ${srv}.sr.ht website service";
311               before = optional cfg.nginx.enable "nginx.service";
312               wants = optional cfg.nginx.enable "nginx.service";
313               wantedBy = [ "multi-user.target" ];
314               path = optional cfg.postgresql.enable postgresql.package;
315               # Beware: change in credentials' content will not trigger restart.
316               restartTriggers = [ configIni ];
317               serviceConfig = {
318                 Type = "simple";
319                 Restart = mkDefault "always";
320                 #RestartSec = mkDefault "2min";
321                 StateDirectory = [ "sourcehut/${srvsrht}" ];
322                 StateDirectoryMode = "2750";
323                 ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs;
324               };
325               preStart =
326                 let
327                   package = pkgs.sourcehut.${srvsrht};
328                   version = package.version;
329                   stateDir = "/var/lib/sourcehut/${srvsrht}";
330                 in
331                 mkBefore ''
332                   set -x
333                   # Use the /run/sourcehut/${srvsrht}/config.ini
334                   # installed by a previous ExecStartPre= in baseService
335                   cd /run/sourcehut/${srvsrht}
337                   if test ! -e ${stateDir}/db; then
338                     # Setup the initial database.
339                     # Note that it stamps the alembic head afterward
340                     ${package}/bin/${srvsrht}-initdb
341                     echo ${version} >${stateDir}/db
342                   fi
344                   ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
345                     if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
346                       # Manage schema migrations using alembic
347                       ${package}/bin/${srvsrht}-migrate -a upgrade head
348                       echo ${version} >${stateDir}/db
349                     fi
350                   ''}
352                   # Update copy of each users' profile to the latest
353                   # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
354                   if test ! -e ${stateDir}/webhook; then
355                     # Update ${iniKey}'s users' profile copy to the latest
356                     ${cfg.python}/bin/srht-update-profiles ${iniKey}
357                     touch ${stateDir}/webhook
358                   fi
359                 '';
360             }
361             mainService
362           ]);
363         }
365         (mkIf webhooks {
366           "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" { }
367             {
368               description = "sourcehut ${srv}.sr.ht webhooks service";
369               after = [ "${srvsrht}.service" ];
370               wantedBy = [ "${srvsrht}.service" ];
371               partOf = [ "${srvsrht}.service" ];
372               preStart = ''
373                 cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
374                    /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
375               '';
376               serviceConfig = {
377                 Type = "simple";
378                 Restart = "always";
379                 ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
380                 # Avoid crashing: os.getloadavg()
381                 ProcSubset = mkForce "all";
382               };
383             };
384         })
386         (mapAttrs
387           (timerName: timer: (baseService timerName { } (mkMerge [
388             {
389               description = "sourcehut ${timerName} service";
390               after = [ "network.target" "${srvsrht}.service" ];
391               serviceConfig = {
392                 Type = "oneshot";
393                 ExecStart = "${pkgs.sourcehut.${srvsrht}}/bin/${timerName}";
394               };
395             }
396             (timer.service or { })
397           ])))
398           extraTimers)
400         (mapAttrs
401           (serviceName: extraService: baseService serviceName { } (mkMerge [
402             {
403               description = "sourcehut ${serviceName} service";
404               # So that extraServices have the PostgreSQL database initialized.
405               after = [ "${srvsrht}.service" ];
406               wantedBy = [ "${srvsrht}.service" ];
407               partOf = [ "${srvsrht}.service" ];
408               serviceConfig = {
409                 Type = "simple";
410                 Restart = mkDefault "always";
411               };
412             }
413             extraService
414           ]))
415           extraServices)
417         # Work around 'pq: permission denied for schema public' with postgres v15.
418         # See https://github.com/NixOS/nixpkgs/issues/216989
419         # Workaround taken from nixos/forgejo: https://github.com/NixOS/nixpkgs/pull/262741
420         # TODO(to maintainers of sourcehut): please migrate away from this workaround
421         # by migrating away from database name defaults with dots.
422         (lib.mkIf
423           (
424             cfg.postgresql.enable
425             && lib.strings.versionAtLeast config.services.postgresql.package.version "15.0"
426           )
427           {
428             postgresql.postStart = (lib.mkAfter ''
429               $PSQL -tAc 'ALTER DATABASE "${srvCfg.postgresql.database}" OWNER TO "${srvCfg.user}";'
430             '');
431           }
432         )
433       ];
435       systemd.timers = mapAttrs
436         (timerName: timer:
437           {
438             description = "sourcehut timer for ${timerName}";
439             wantedBy = [ "timers.target" ];
440             inherit (timer) timerConfig;
441           })
442         extraTimers;
443     }
444   ]);