nix: avoid sending nixpkgs on non-builder target
[sourcephile-nix.git] / nixos / modules / services / misc / sourcehut / service.nix
index ec401ba60926cba807b613ed981db59486e9acc1..b3c0efc07ddfd453851acddf78d7cfe3a596dceb 100644 (file)
@@ -2,10 +2,8 @@ srv:
 { configIniOfService
 , srvsrht ? "${srv}srht" # Because "buildsrht" does not follow that pattern (missing an "s").
 , iniKey ? "${srv}.sr.ht"
-, webhooks ? null
-, redisDatabase ? null
+, webhooks ? false
 , extraTimers ? {}
-, commonService ? {}
 , mainService ? {}
 , extraServices ? {}
 , extraConfig ? {}
@@ -15,46 +13,58 @@ srv:
 
 with lib;
 let
-  inherit (config.services) postgresql redis;
+  inherit (config.services) postgresql;
+  redis = config.services.redis.servers."sourcehut-${srvsrht}";
   inherit (config.users) users;
   cfg = config.services.sourcehut;
   configIni = configIniOfService srv;
-  domain = cfg.settings."sr.ht".global-domain;
   srvCfg = cfg.${srv};
-  baseService = serviceName: { noStripe ? true }: extraService: let
+  baseService = serviceName: { allowStripe ? false }: extraService: let
     runDir = "/run/sourcehut/${serviceName}";
     rootDir = "/run/sourcehut/chroots/${serviceName}";
     in
-    mkMerge [ commonService extraService {
+    mkMerge [ extraService {
+    after = [ "network.target" ] ++
+      optional cfg.postgresql.enable "postgresql.service" ++
+      optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
+    requires =
+      optional cfg.postgresql.enable "postgresql.service" ++
+      optional cfg.redis.enable "redis-sourcehut-${srvsrht}.service";
     path = [ pkgs.gawk ];
     environment.HOME = runDir;
     serviceConfig = {
+      User = mkDefault srvCfg.user;
+      Group = mkDefault srvCfg.group;
       RuntimeDirectory = [
         "sourcehut/${serviceName}"
+        # Used by *srht-keys which reads ../config.ini
+        "sourcehut/${serviceName}/subdir"
         "sourcehut/chroots/${serviceName}"
       ];
       RuntimeDirectoryMode = "2750";
       # No need for the chroot path once inside the chroot
       InaccessiblePaths = [ "-+${rootDir}" ];
-      # For intermediate directories created by BindPaths= and others
-      UMask = "0066";
+      # g+rx is for group members (eg. fcgiwrap or nginx)
+      # to read Git/Mercurial repositories, buildlogs, etc.
+      # o+x is for intermediate directories created by BindPaths= and like,
+      # as they're owned by root:root.
+      UMask = "0026";
       RootDirectory = rootDir;
       RootDirectoryStartOnly = true;
       PrivateTmp = true;
       MountAPIVFS = true;
       # config.ini is looked up in there, before /etc/srht/config.ini
       # Note that it fails to be set in ExecStartPre=
-      WorkingDirectory = "-"+runDir;
+      WorkingDirectory = mkDefault ("-"+runDir);
       BindReadOnlyPaths = [
         builtins.storeDir
         "/etc"
         "/run/booted-system"
         "/run/current-system"
         "/run/systemd"
-        "/run/wrappers"
         ] ++
-        optional cfg.redis.enable "/run/redis" ++
-        optional cfg.postgresql.enable "/run/postgresql";
+        optional cfg.postgresql.enable "/run/postgresql" ++
+        optional cfg.redis.enable "/run/redis-sourcehut-${srvsrht}";
       # LoadCredential= are unfortunately not available in ExecStartPre=
       # Hence this one is run as root (the +) with RootDirectoryStartOnly=
       # to reach credentials wherever they are.
@@ -63,10 +73,9 @@ let
         set -x
         # Replace values begining with a '<' by the content of the file whose name is after.
         gawk '{ if (match($0,/^([^=]+=)<(.+)/,m)) { getline f < m[2]; print m[1] f } else print $0 }' ${configIni} |
-        ${optionalString noStripe "gawk '!/^stripe-secret-key=/' |"}
+        ${optionalString (!allowStripe) "gawk '!/^stripe-secret-key=/' |"}
         install -o ${srvCfg.user} -g root -m 400 /dev/stdin ${runDir}/config.ini
       '')];
-      LogsDirectory = [ "sourcehut/${serviceName}" ];
       # The following options are only for optimizing:
       # systemd-analyze security
       AmbientCapabilities = "";
@@ -118,20 +127,68 @@ in
       '';
     };
 
+    group = mkOption {
+      type = types.str;
+      default = srvsrht;
+      description = ''
+        Group for ${srv}.sr.ht.
+        Membership grants access to the Git/Mercurial repositories by default,
+        but not to the config.ini file (where secrets are).
+      '';
+    };
+
     port = mkOption {
       type = types.port;
       default = port;
       description = ''
-        Port on which the "${srv}" module should listen.
+        Port on which the "${srv}" backend should listen.
       '';
     };
 
-    database = mkOption {
-      type = types.str;
-      default = "${srv}.sr.ht";
-      description = ''
-        PostgreSQL database name for ${srv}.sr.ht.
-      '';
+    redis = {
+      host = mkOption {
+        type = types.str;
+        default = "unix:/run/redis-sourcehut-${srvsrht}/redis.sock?db=0";
+        example = "redis://shared.wireguard:6379/0";
+        description = ''
+          The redis host URL. This is used for caching and temporary storage, and must
+          be shared between nodes (e.g. git1.sr.ht and git2.sr.ht), but need not be
+          shared between services. It may be shared between services, however, with no
+          ill effect, if this better suits your infrastructure.
+        '';
+      };
+    };
+
+    postgresql = {
+      database = mkOption {
+        type = types.str;
+        default = "${srv}.sr.ht";
+        description = ''
+          PostgreSQL database name for the ${srv}.sr.ht service,
+          used if <xref linkend="opt-services.sourcehut.postgresql.enable"/> is <literal>true</literal>.
+        '';
+      };
+    };
+
+    gunicorn = {
+      extraArgs = mkOption {
+        type = with types; listOf str;
+        default = ["--timeout 120" "--workers 1" "--log-level=info"];
+        description = "Extra arguments passed to Gunicorn.";
+      };
+    };
+  } // optionalAttrs webhooks {
+    webhooks = {
+      extraArgs = mkOption {
+        type = with types; listOf str;
+        default = ["--loglevel DEBUG" "--pool eventlet" "--without-heartbeat"];
+        description = "Extra arguments passed to the Celery responsible for webhooks.";
+      };
+      celeryConfig = mkOption {
+        type = types.lines;
+        default = "";
+        description = "Content of the <literal>celeryconfig.py</literal> used by the Celery responsible for webhooks.";
+      };
     };
   };
 
@@ -140,40 +197,43 @@ in
       users = {
         "${srvCfg.user}" = {
           isSystemUser = true;
-          group = mkDefault srvCfg.user;
+          group = mkDefault srvCfg.group;
           description = mkDefault "sourcehut user for ${srv}.sr.ht";
         };
       };
       groups = {
-        "${srvCfg.user}" = { };
+        "${srvCfg.group}" = { };
       } // optionalAttrs (cfg.postgresql.enable
         && hasSuffix "0" (postgresql.settings.unix_socket_permissions or "")) {
         "postgres".members = [ srvCfg.user ];
+      } // optionalAttrs (cfg.redis.enable
+        && hasSuffix "0" (redis.settings.unixsocketperm or "")) {
+        "redis-sourcehut-${srvsrht}".members = [ srvCfg.user ];
       };
     };
 
-    services.nginx.virtualHosts = mkIf cfg.nginx.enable {
-      "${srv}.${domain}" = {
+    services.nginx = mkIf cfg.nginx.enable {
+      virtualHosts."${srv}.${cfg.settings."sr.ht".global-domain}" = mkMerge [ {
         forceSSL = true;
         locations."/".proxyPass = "http://${cfg.listenAddress}:${toString srvCfg.port}";
-        locations."/query".proxyPass = cfg.settings."meta.sr.ht".api-origin;
-        locations."/static".root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}";
-      };
+        locations."/static" = {
+          root = "${pkgs.sourcehut.${srvsrht}}/${pkgs.sourcehut.python.sitePackages}/${srvsrht}";
+          extraConfig = mkDefault ''
+            expires 30d;
+          '';
+        };
+      } cfg.nginx.virtualHost ];
     };
 
     services.postgresql = mkIf cfg.postgresql.enable {
       authentication = ''
-        local ${srvCfg.database} ${srvCfg.user} trust
-      ''
-      # shrt-keys stores SSH keys in the PostgreSQL database of the service calling it
-      + optionalString (elem srv ["builds" "git" "hg"]) ''
-        local ${srvCfg.database} ${users."sshsrht".name} trust
+        local ${srvCfg.postgresql.database} ${srvCfg.user} trust
       '';
-      ensureDatabases = [ srvCfg.database ];
+      ensureDatabases = [ srvCfg.postgresql.database ];
       ensureUsers = map (name: {
           inherit name;
-          ensurePermissions = { "DATABASE \"${srvCfg.database}\"" = "ALL PRIVILEGES"; };
-        }) ([srvCfg.user] ++ optional (elem srv ["builds" "git" "hg"]) users."sshsrht".name);
+          ensurePermissions = { "DATABASE \"${srvCfg.postgresql.database}\"" = "ALL PRIVILEGES"; };
+        }) [srvCfg.user];
     };
 
     services.sourcehut.services = mkDefault (filter (s: cfg.${s}.enable)
@@ -181,45 +241,45 @@ in
 
     services.sourcehut.settings = mkMerge [
       {
-        "${srv}.sr.ht" = {
-          origin = mkDefault "https://${srv}.${domain}";
-        };
+        "${srv}.sr.ht".origin = mkDefault "https://${srv}.${cfg.settings."sr.ht".global-domain}";
       }
 
-      (mkIf (cfg.redis.enable && webhooks != null) {
-        "${srv}.sr.ht".webhooks = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + webhooks.redisDatabase)}";
-      })
-
-      (mkIf (cfg.redis.enable && redisDatabase != null) {
-        "${srv}.sr.ht".redis = mkDefault "redis://localhost:${toString redis.port}/${toString (cfg.redis.firstDatabase + redisDatabase)}";
-      })
-
       (mkIf cfg.postgresql.enable {
-        "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.database}?user=${srvCfg.user}&host=/run/postgresql";
+        "${srv}.sr.ht".connection-string = mkDefault "postgresql:///${srvCfg.postgresql.database}?user=${srvCfg.user}&host=/run/postgresql";
       })
     ];
 
+    services.redis.servers."sourcehut-${srvsrht}" = mkIf cfg.redis.enable {
+      enable = true;
+      databases = 3;
+      syslog = true;
+      # TODO: set a more informed value
+      save = mkDefault [ [1800 10] [300 100] ];
+      settings = {
+        # TODO: set a more informed value
+        maxmemory = "128MB";
+        maxmemory-policy = "volatile-ttl";
+      };
+    };
+
     systemd.services = mkMerge [
-      { "${srvsrht}" = baseService srvsrht { noStripe = srv != "meta"; }
-        (mkMerge [ {
+      {
+        "${srvsrht}" = baseService srvsrht { allowStripe = srv == "meta"; } (mkMerge [
+        {
           description = "sourcehut ${srv}.sr.ht website service";
-          after = [ "network.target" ]
-            ++ optional cfg.postgresql.enable "postgresql.service"
-            ++ optional (srv != "meta" && cfg.meta.enable) "metasrht-api.service";
-          requires = optional cfg.postgresql.enable "postgresql.service";
+          before = optional cfg.nginx.enable "nginx.service";
+          wants = optional cfg.nginx.enable "nginx.service";
           wantedBy = [ "multi-user.target" ];
-          path = optional cfg.postgresql.enable config.services.postgresql.package;
+          path = optional cfg.postgresql.enable postgresql.package;
           # Beware: change in credentials' content will not trigger restart.
           restartTriggers = [ configIni ];
           serviceConfig = {
             Type = "simple";
-            User = srvCfg.user;
-            Group = srvCfg.user;
             Restart = mkDefault "always";
             #RestartSec = mkDefault "2min";
             StateDirectory = [ "sourcehut/${srvsrht}" ];
             StateDirectoryMode = "2750";
-            ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app -b ${cfg.listenAddress}:${toString srvCfg.port}";
+            ExecStart = "${cfg.python}/bin/gunicorn ${srvsrht}.app:app --name ${srvsrht} --bind ${cfg.listenAddress}:${toString srvCfg.port} " + concatStringsSep " " srvCfg.gunicorn.extraArgs;
           };
           preStart = let
             version = pkgs.sourcehut.${srvsrht}.version;
@@ -231,101 +291,85 @@ in
             cd /run/sourcehut/${srvsrht}
 
             if test ! -e ${stateDir}/db; then
-              # Setup the initial database
-              ${cfg.python}/bin/python <<EOF
-            from ${srvsrht}.app import db
-            db.create()
-            EOF
+              # Setup the initial database.
+              # Note that it stamps the alembic head afterward
+              ${cfg.python}/bin/${srvsrht}-initdb
+              echo ${version} >${stateDir}/db
+            fi
 
-              # Set the initial state of the database for future database upgrades
-              if test -e ${cfg.python}/bin/${srvsrht}-migrate; then
-                # Run alembic stamp head once to tell alembic the schema is up-to-date
-                ${cfg.python}/bin/${srvsrht}-migrate stamp head
+            ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
+              if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
+                # Manage schema migrations using alembic
+                ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
+                echo ${version} >${stateDir}/db
               fi
-
-              echo ${version} > ${stateDir}/db
-            fi
+            ''}
 
             # Update copy of each users' profile to the latest
             # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
             if test ! -e ${stateDir}/webhook; then
               # Update ${iniKey}'s users' profile copy to the latest
               ${cfg.python}/bin/srht-update-profiles ${iniKey}
-
               touch ${stateDir}/webhook
             fi
-
-            ${optionalString cfg.settings.${iniKey}.migrate-on-upgrade ''
-              if [ "$(cat ${stateDir}/db)" != "${version}" ]; then
-                # Manage schema migrations using alembic
-                ${cfg.python}/bin/${srvsrht}-migrate -a upgrade head
-
-                # Mark down current package version
-                echo ${version} > ${stateDir}/db
-              fi
-            ''}
           '';
         } mainService ]);
       }
 
-      (mkIf (webhooks != null) {
+      (mkIf webhooks {
         "${srvsrht}-webhooks" = baseService "${srvsrht}-webhooks" {}
-        {
-          description = "sourcehut ${srv}.sr.ht webhooks service";
-          after = [ "${srvsrht}.service" ]
-            ++ optional cfg.redis.enable "redis.service";
-          requires = [ "${srvsrht}.service" ]
-            ++ optional cfg.redis.enable "redis.service";
-          wantedBy = [ "${srvsrht}.service" ];
-          requiredBy = [ "${srvsrht}.service" ];
-          serviceConfig = {
-            Type = "simple";
-            User = srvCfg.user;
-            Group = mkDefault srvCfg.user;
-            Restart = "always";
-            ExecStart = "${cfg.python}/bin/celery -A ${srvsrht}.webhooks worker --loglevel INFO --pool eventlet";
-            # Avoid crashing: os.getloadavg()
-            ProcSubset = mkForce "all";
+          {
+            description = "sourcehut ${srv}.sr.ht webhooks service";
+            after = [ "${srvsrht}.service" ];
+            wantedBy = [ "${srvsrht}.service" ];
+            partOf = [ "${srvsrht}.service" ];
+            preStart = ''
+              cp ${pkgs.writeText "${srvsrht}-webhooks-celeryconfig.py" srvCfg.webhooks.celeryConfig} \
+                 /run/sourcehut/${srvsrht}-webhooks/celeryconfig.py
+            '';
+            serviceConfig = {
+              Type = "simple";
+              Restart = "always";
+              ExecStart = "${cfg.python}/bin/celery --app ${srvsrht}.webhooks worker --hostname ${srvsrht}-webhooks@%%h " + concatStringsSep " " srvCfg.webhooks.extraArgs;
+              # Avoid crashing: os.getloadavg()
+              ProcSubset = mkForce "all";
+            };
           };
-        };
       })
 
-      (mapAttrs (timerName: timerConfig: (baseService timerName {}
+      (mapAttrs (timerName: timer: (baseService timerName {} (mkMerge [
         {
           description = "sourcehut ${timerName} service";
-          after = [ "${srvsrht}.service" ];
-          requires = [ "${srvsrht}.service" ];
+          after = [ "network.target" "${srvsrht}.service" ];
           serviceConfig = {
             Type = "oneshot";
-            User = srvCfg.user;
-            Group = mkDefault srvCfg.user;
-            Restart = "no";
             ExecStart = "${cfg.python}/bin/${timerName}";
           };
-        })
-      ) extraTimers)
+        }
+        (timer.service or {})
+      ]))) extraTimers)
 
-      (mapAttrs (serviceName: service: baseService serviceName {}
-        (mkMerge [ service {
-          description = "sourcehut ${serviceName}";
+      (mapAttrs (serviceName: extraService: baseService serviceName {} (mkMerge [
+        {
+          description = "sourcehut ${serviceName} service";
+          # So that extraServices have the PostgreSQL database initialized.
           after = [ "${srvsrht}.service" ];
-          requires = [ "${srvsrht}.service" ];
-          wantedBy = [ "multi-user.target" ];
+          wantedBy = [ "${srvsrht}.service" ];
+          partOf = [ "${srvsrht}.service" ];
           serviceConfig = {
             Type = "simple";
-            User = srvCfg.user;
-            Group = mkDefault srvCfg.user;
             Restart = mkDefault "always";
           };
-        } ])
-      ) extraServices)
+        }
+        extraService
+      ])) extraServices)
     ];
 
-    systemd.timers = mapAttrs (timerName: timerConfig:
+    systemd.timers = mapAttrs (timerName: timer:
       {
         description = "sourcehut timer for ${timerName}";
         wantedBy = [ "timers.target" ];
-        inherit timerConfig;
+        inherit (timer) timerConfig;
       }) extraTimers;
   } ]);
 }