From 64f9c051b994c06e6e0fc1ade17be7801bd3a1f0 Mon Sep 17 00:00:00 2001
From: Sivert Sliper <10866270+sivertism@users.noreply.github.com>
Date: Mon, 4 Mar 2024 02:25:26 +0100
Subject: [PATCH] Grocy service (#195)

PR to add grocy as a service.

I think LDAP should be [relatively
simple](https://www.reddit.com/r/grocy/comments/18avtb7/sso_tutorial/)
to add, but couldn't find good information on SSO.

Will test this out for a while to make sure it really works before this
can be merged.

---------

Co-authored-by: ibizaman <ibizapeanut@gmail.com>
Co-authored-by: Pierre Penninckx <github@pierre.tiserbox.com>
---
 flake.nix                  |   6 +-
 modules/services/grocy.nix | 106 ++++++++++++++++++++++++++++++
 test/vm/grocy.nix          | 128 +++++++++++++++++++++++++++++++++++++
 3 files changed, 238 insertions(+), 2 deletions(-)
 create mode 100644 modules/services/grocy.nix
 create mode 100644 test/vm/grocy.nix

diff --git a/flake.nix b/flake.nix
index 86a25f8..daf887d 100644
--- a/flake.nix
+++ b/flake.nix
@@ -40,13 +40,14 @@
         modules/blocks/vpn.nix
 
         modules/services/arr.nix
+        modules/services/audiobookshelf.nix
         modules/services/deluge.nix
+        modules/services/grocy.nix
         modules/services/hledger.nix
         modules/services/home-assistant.nix
         modules/services/jellyfin.nix
         modules/services/nextcloud-server.nix
         modules/services/vaultwarden.nix
-        modules/services/audiobookshelf.nix
       ];
     in
       {
@@ -102,12 +103,13 @@
           }
           // (vm_test "audiobookshelf" ./test/vm/audiobookshelf.nix)
           // (vm_test "authelia" ./test/vm/authelia.nix)
+          // (vm_test "grocy" ./test/vm/grocy.nix)
           // (vm_test "jellyfin" ./test/vm/jellyfin.nix)
           // (vm_test "ldap" ./test/vm/ldap.nix)
           // (vm_test "lib" ./test/vm/lib.nix)
-          // (vm_test "postgresql" ./test/vm/postgresql.nix)
           // (vm_test "monitoring" ./test/vm/monitoring.nix)
           // (vm_test "nextcloud" ./test/vm/nextcloud.nix)
+          // (vm_test "postgresql" ./test/vm/postgresql.nix)
           // (vm_test "ssl" ./test/vm/ssl.nix)
           );
       }
diff --git a/modules/services/grocy.nix b/modules/services/grocy.nix
new file mode 100644
index 0000000..fb57a89
--- /dev/null
+++ b/modules/services/grocy.nix
@@ -0,0 +1,106 @@
+{ config, pkgs, lib, ... }:
+
+let
+  cfg = config.shb.grocy;
+
+  contracts = pkgs.callPackage ../contracts {};
+
+  fqdn = "${cfg.subdomain}.${cfg.domain}";
+in
+{
+  options.shb.grocy = {
+    enable = lib.mkEnableOption "selfhostblocks.grocy";
+
+    subdomain = lib.mkOption {
+      type = lib.types.str;
+      description = "Subdomain under which grocy will be served.";
+      example = "grocy";
+    };
+
+    domain = lib.mkOption {
+      type = lib.types.str;
+      description = "domain under which grocy will be served.";
+      example = "mydomain.com";
+    };
+
+    dataDir = lib.mkOption {
+      description = "Folder where Grocy will store all its data.";
+      type = lib.types.str;
+      default = "/var/lib/grocy";
+    };
+
+    currency = lib.mkOption {
+      type = lib.types.str;
+      description = "ISO 4217 code for the currency to display.";
+      default = "USD";
+      example = "NOK";
+    };
+
+    culture = lib.mkOption {
+      type = lib.types.enum [ "de" "en" "da" "en_GB" "es" "fr" "hu" "it" "nl" "no" "pl" "pt_BR" "ru" "sk_SK" "sv_SE" "tr" ];
+      default = "en";
+      description = lib.mdDoc ''
+        Display language of the frontend.
+      '';
+    };
+
+    ssl = lib.mkOption {
+      description = "Path to SSL files";
+      type = lib.types.nullOr contracts.ssl.certs;
+      default = null;
+    };
+
+    extraServiceConfig = lib.mkOption {
+      type = lib.types.attrsOf lib.types.str;
+      description = "Extra configuration given to the systemd service file.";
+      default = {};
+      example = lib.literalExpression ''
+      {
+        MemoryHigh = "512M";
+        MemoryMax = "900M";
+      }
+      '';
+    };
+
+    logLevel = lib.mkOption {
+      type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]);
+      description = "Enable logging.";
+      default = false;
+      example = true;
+    };
+  };
+
+  config = lib.mkIf cfg.enable (lib.mkMerge [{
+
+    services.grocy = {
+      enable = true;
+      hostName = fqdn;
+      nginx.enableSSL = !(isNull cfg.ssl);
+      dataDir = cfg.dataDir;
+      settings.currency = cfg.currency;
+      settings.culture = cfg.culture;
+    };
+
+    services.phpfpm.pools.grocy.group = lib.mkForce "grocy";
+
+    users.groups.grocy = {};
+    users.users.grocy.group = lib.mkForce "grocy";
+
+    services.nginx.virtualHosts."${fqdn}" = {
+      enableACME = lib.mkForce false;
+      sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;
+      sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;
+    };
+
+    # We backup the whole grocy directory and set permissions for the backup user accordingly.
+    users.groups.grocy.members = [ "backup" ];
+    users.groups.media.members = [ "backup" ];
+    shb.backup.instances.grocy = {
+      sourceDirectories = [
+        config.services.grocy.dataDir
+      ];
+    };
+  } {
+    systemd.services.grocyd.serviceConfig = cfg.extraServiceConfig;
+  }]);
+}
diff --git a/test/vm/grocy.nix b/test/vm/grocy.nix
new file mode 100644
index 0000000..08fe0ae
--- /dev/null
+++ b/test/vm/grocy.nix
@@ -0,0 +1,128 @@
+{ pkgs, lib, ... }:
+{
+  basic = pkgs.nixosTest {
+    name = "grocy-basic";
+
+    nodes.server = { config, pkgs, ... }: {
+      imports = [
+        {
+          options = {
+            shb.backup = lib.mkOption { type = lib.types.anything; };
+          };
+        }
+        ../../modules/services/grocy.nix
+      ];
+
+      shb.grocy = {
+        enable = true;
+        domain = "example.com";
+        subdomain = "g";
+      };
+      # Nginx port.
+      networking.firewall.allowedTCPPorts = [ 80 ];
+    };
+
+    nodes.client = {};
+
+    # TODO: Test login
+    testScript = { nodes, ... }: ''
+    import json
+
+    def curl(target, format, endpoint):
+        return json.loads(target.succeed(
+            "curl --fail-with-body --silent --show-error --output /dev/null --location"
+            + " --connect-to g.example.com:443:server:443"
+            + " --connect-to g.example.com:80:server:80"
+            + f" --write-out '{format}'"
+            + " " + endpoint
+        ))
+
+    start_all()
+    server.wait_for_unit("phpfpm-grocy.service")
+    server.wait_for_unit("nginx.service")
+    server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.grocy.socket}")
+
+    response = curl(client, """{"code":%{response_code}}""", "http://g.example.com")
+
+    if response['code'] != 200:
+        raise Exception(f"Code is {response['code']}")
+    '';
+  };
+
+  cert = pkgs.nixosTest {
+    name = "grocy-cert";
+
+    nodes.server = { config, pkgs, ... }: {
+      imports = [
+        {
+          options = {
+            shb.backup = lib.mkOption { type = lib.types.anything; };
+            shb.authelia = lib.mkOption { type = lib.types.anything; };
+          };
+        }
+        ../../modules/blocks/nginx.nix
+        ../../modules/blocks/ssl.nix
+        ../../modules/services/grocy.nix
+      ];
+
+      shb.certs = {
+        cas.selfsigned.myca = {
+          name = "My CA";
+        };
+        certs.selfsigned = {
+          n = {
+            ca = config.shb.certs.cas.selfsigned.myca;
+            domain = "*.example.com";
+            group = "nginx";
+          };
+        };
+      };
+
+      systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ];
+      systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];
+
+      shb.grocy = {
+        enable = true;
+        domain = "example.com";
+        subdomain = "g";
+        ssl = config.shb.certs.certs.selfsigned.n;
+      };
+      # Nginx port.
+      networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+      shb.nginx.accessLog = true;
+    };
+
+    nodes.client = {};
+
+    # TODO: Test login
+    testScript = { nodes, ... }: ''
+    import json
+    import os
+    import pathlib
+
+    def curl(target, format, endpoint):
+        return json.loads(target.succeed(
+            "curl --fail-with-body --silent --show-error --output /dev/null --location"
+            + " --connect-to g.example.com:443:server:443"
+            + " --connect-to g.example.com:80:server:80"
+            + f" --write-out '{format}'"
+            + " " + endpoint
+        ))
+
+    start_all()
+    server.wait_for_unit("phpfpm-grocy.service")
+    server.wait_for_unit("nginx.service")
+    server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.grocy.socket}")
+
+    server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt")
+    client.succeed("rm -r /etc/ssl/certs")
+    client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt")
+
+    response = curl(client, """{"code":%{response_code}}""", "https://g.example.com")
+
+    if response['code'] != 200:
+        raise Exception(f"Code is {response['code']}")
+    '';
+  };
+}