From 5179f7fc904c74a90c1ce74e62082c4c7c7e44f2 Mon Sep 17 00:00:00 2001 From: Pierre Penninckx Date: Mon, 8 Apr 2024 22:41:52 -0700 Subject: [PATCH] Add external storage app to Nextcloud (#222) --- modules/services/nextcloud-server.nix | 76 ++++++++- .../services/nextcloud-server/docs/default.md | 31 ++++ test/vm/nextcloud.nix | 152 ++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) diff --git a/modules/services/nextcloud-server.nix b/modules/services/nextcloud-server.nix index eaba074..c08b38d 100644 --- a/modules/services/nextcloud-server.nix +++ b/modules/services/nextcloud-server.nix @@ -277,6 +277,58 @@ in }; }; + externalStorage = lib.mkOption { + # TODO: would be nice to have quota include external storage but it's not supported for root: + # https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html#setting-storage-quotas + description = '' + External Storage App. [Manual](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) + + Set `userLocalMount` to automatically add a local directory as an external storage. + Use this option if you want to store user data in another folder or another hard drive + altogether. + + In the `directory` option, you can use either `$user` and/or `$home` which will be + replaced by the user's name and home directory. + + Recommended use of this option is to have the Nextcloud's `dataDir` on a SSD and the + `userLocalRooDirectory` on a HDD. Indeed, a SSD is much quicker than a spinning hard + drive, which is well suited for randomly accessing small files like thumbnails. On the + other side, a spinning hard drive can store more data which is well suited for storing + user data. + ''; + default = {}; + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "Nextcloud External Storage App"; + userLocalMount = lib.mkOption { + default = null; + description = "If set, adds a local mount as external storage."; + type = lib.types.nullOr (lib.types.submodule { + options = { + directory = lib.mkOption { + type = lib.types.str; + description = '' + Local directory on the filesystem to mount. Use `$user` and/or `$home` + which will be replaced by the user's name and home directory. + ''; + example = "/srv/nextcloud/$user"; + }; + + mountName = lib.mkOption { + type = lib.types.str; + description = '' + Path of the mount in Nextcloud. Use `/` to mount as the root. + ''; + default = ""; + example = [ "home" "/" ]; + }; + }; + }); + }; + }; + }; + }; + ldap = lib.mkOption { description = '' LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html) @@ -700,7 +752,7 @@ in systemd.timers.nextcloud-cron-previewgenerator = { wantedBy = [ "timers.target" ]; requires = cfg.mountPointServices; - after = [ "nextcloud-setup.service" ] + cfg.mountPointServices; + after = [ "nextcloud-setup.service" ] ++ cfg.mountPointServices; timerConfig.OnBootSec = "10m"; timerConfig.OnUnitActiveSec = "10m"; timerConfig.Unit = "nextcloud-cron-previewgenerator.service"; @@ -718,6 +770,28 @@ in }; }) + (lib.mkIf cfg.apps.externalStorage.enable { + systemd.services.nextcloud-setup.script = '' + ${occ} app:install files_external || : + ${occ} app:enable files_external + '' + lib.optionalString (cfg.apps.externalStorage.userLocalMount != "") ( + let + cfg' = cfg.apps.externalStorage.userLocalMount; + + escape = x: builtins.replaceStrings ["/"] [''\\\/''] x; + in + '' + ${occ} files_external:list \ + | grep '${escape cfg'.mountName}' \ + | grep '${escape cfg'.directory}' \ + || ${occ} files_external:create \ + '${cfg'.mountName}' \ + local \ + null::null \ + --config datadir='${cfg'.directory}' + ''); + }) + (lib.mkIf cfg.apps.ldap.enable { systemd.services.nextcloud-setup.path = [ pkgs.jq ]; systemd.services.nextcloud-setup.script = '' diff --git a/modules/services/nextcloud-server/docs/default.md b/modules/services/nextcloud-server/docs/default.md index d89e2f1..80bccb1 100644 --- a/modules/services/nextcloud-server/docs/default.md +++ b/modules/services/nextcloud-server/docs/default.md @@ -12,6 +12,8 @@ This NixOS module is a service that sets up a [Nextcloud Server](https://nextclo - [OIDC](#services-nextcloud-server-usage-oidc) app: enables app and sets up integration with an existing OIDC server. - [Preview Generator](#services-nextcloud-server-usage-previewgenerator) app: enables app and sets up required cron job. + - [External Storage](#services-nextcloud-server-usage-externalstorage) app: enables app and + optionally configures one local mount. - [Only Office](#services-nextcloud-server-usage-onlyoffice) app: enables app and sets up Only Office service. - Any other app through the @@ -302,6 +304,35 @@ You can opt-out with: shb.nextcloud.apps.previewgenerator.recommendedSettings = false; ``` +### Enable External Storage App {#services-nextcloud-server-usage-externalstorage} + +The following snippet installs and enables the [External +Storage](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) application. + +```nix +shb.nextcloud.apps.externalStorage.enable = true; +``` + +Optionally creates a local mount point with: + +```nix +externalStorage = { + userLocalMount.rootDirectory = "/srv/nextcloud/$user"; + userLocalMount.mountName = "home"; +}; +``` + +You can even make the external storage be at the root with: + +```nix +externalStorage.userLocalMount.mountName = "/"; +``` + +Recommended use of this app is to have the Nextcloud's `dataDir` on a SSD and the +`userLocalRooDirectory` on a HDD. Indeed, a SSD is much quicker than a spinning hard drive, which is +well suited for randomly accessing small files like thumbnails. On the other side, a spinning hard +drive can store more data which is well suited for storing user data. + ### Enable OnlyOffice App {#services-nextcloud-server-usage-onlyoffice} The following snippet installs and enables the [Only diff --git a/test/vm/nextcloud.nix b/test/vm/nextcloud.nix index 4d5056c..b1d7d8d 100644 --- a/test/vm/nextcloud.nix +++ b/test/vm/nextcloud.nix @@ -230,4 +230,156 @@ in # TODO: Test login testScript = commonTestScript; }; + + previewGenerator = pkgs.testers.runNixOSTest { + name = "nextcloud-previewGenerator"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + (pkgs'.path + "/nixos/modules/profiles/headless.nix") + (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/services/nextcloud-server.nix + ]; + + systemd.tmpfiles.rules = [ + "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" + ]; + + shb.nextcloud = { + enable = true; + domain = "example.com"; + subdomain = "n"; + dataDir = "/var/lib/nextcloud"; + tracing = null; + defaultPhoneRegion = "US"; + + # This option is only needed because we do not access Nextcloud at the default port in the VM. + externalFqdn = "n.example.com:8080"; + + adminUser = adminUser; + adminPassFile = pkgs.writeText "adminPassFile" adminPass; + debug = true; + + apps.previewgenerator.enable = true; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 ]; + # VM needs a bit more memory than default. + virtualisation.memorySize = 4096; + }; + + nodes.client = {}; + + testScript = { nodes, ... }: + '' + import json + + start_all() + server.wait_for_unit("phpfpm-nextcloud.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.nextcloud.socket}") + + def find_in_logs(unit, text): + return server.systemctl("status {}".format(unit))[1].find(text) != -1 + + def curl(target, format, endpoint, succeed=True): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to n.example.com:443:server:443" + + " --connect-to n.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + with subtest("access"): + response = curl(client, """{"code":%{response_code}}""", "http://n.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + externalStorage = pkgs.testers.runNixOSTest { + name = "nextcloud-externalStorage"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + (pkgs'.path + "/nixos/modules/profiles/headless.nix") + (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/services/nextcloud-server.nix + ]; + + systemd.tmpfiles.rules = [ + "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" + ]; + + shb.nextcloud = { + enable = true; + domain = "example.com"; + subdomain = "n"; + dataDir = "/var/lib/nextcloud"; + tracing = null; + defaultPhoneRegion = "US"; + + # This option is only needed because we do not access Nextcloud at the default port in the VM. + externalFqdn = "n.example.com:8080"; + + adminUser = adminUser; + adminPassFile = pkgs.writeText "adminPassFile" adminPass; + debug = true; + + apps.externalStorage = { + enable = true; + userLocalMount.directory = "/srv/nextcloud/$user"; + userLocalMount.mountName = "home"; + }; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 ]; + # VM needs a bit more memory than default. + virtualisation.memorySize = 4096; + }; + + nodes.client = {}; + + testScript = { nodes, ... }: + '' + import json + + start_all() + server.wait_for_unit("phpfpm-nextcloud.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.nextcloud.socket}") + + def find_in_logs(unit, text): + return server.systemctl("status {}".format(unit))[1].find(text) != -1 + + def curl(target, format, endpoint, succeed=True): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to n.example.com:443:server:443" + + " --connect-to n.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + with subtest("access"): + response = curl(client, """{"code":%{response_code}}""", "http://n.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; }