From d22d60f3acbba13db9242e40606b6c15b7ed65fb Mon Sep 17 00:00:00 2001 From: Markus Kowalewski Date: Thu, 3 Oct 2024 11:39:24 +0200 Subject: [PATCH] nixos/saunafs: add module + test --- nixos/modules/module-list.nix | 1 + .../services/network-filesystems/saunafs.nix | 287 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/saunafs.nix | 122 ++++++++ 4 files changed, 411 insertions(+) create mode 100644 nixos/modules/services/network-filesystems/saunafs.nix create mode 100644 nixos/tests/saunafs.nix diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 193cb43118f4..345adaa3c26b 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -966,6 +966,7 @@ ./services/network-filesystems/rsyncd.nix ./services/network-filesystems/samba-wsdd.nix ./services/network-filesystems/samba.nix + ./services/network-filesystems/saunafs.nix ./services/network-filesystems/tahoe.nix ./services/network-filesystems/u9fs.nix ./services/network-filesystems/webdav-server-rs.nix diff --git a/nixos/modules/services/network-filesystems/saunafs.nix b/nixos/modules/services/network-filesystems/saunafs.nix new file mode 100644 index 000000000000..5c3c513c06f7 --- /dev/null +++ b/nixos/modules/services/network-filesystems/saunafs.nix @@ -0,0 +1,287 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.saunafs; + + settingsFormat = + let + listSep = " "; + allowedTypes = with lib.types; [ + bool + int + float + str + ]; + valueToString = + val: + if lib.isList val then + lib.concatStringsSep listSep (map (x: valueToString x) val) + else if lib.isBool val then + (if val then "1" else "0") + else + toString val; + + in + { + type = + let + valueType = + lib.types.oneOf ( + [ + (lib.types.listOf valueType) + ] + ++ allowedTypes + ) + // { + description = "Flat key-value file"; + }; + in + lib.types.attrsOf valueType; + + generate = + name: value: + pkgs.writeText name ( + lib.concatStringsSep "\n" (lib.mapAttrsToList (key: val: "${key} = ${valueToString val}") value) + ); + }; + + initTool = pkgs.writeShellScriptBin "sfsmaster-init" '' + if [ ! -e ${cfg.master.settings.DATA_PATH}/metadata.sfs ]; then + cp --update=none ${pkgs.saunafs}/var/lib/saunafs/metadata.sfs.empty ${cfg.master.settings.DATA_PATH}/metadata.sfs + chmod +w ${cfg.master.settings.DATA_PATH}/metadata.sfs + fi + ''; + + # master config file + masterCfg = settingsFormat.generate "sfsmaster.cfg" cfg.master.settings; + + # metalogger config file + metaloggerCfg = settingsFormat.generate "sfsmetalogger.cfg" cfg.metalogger.settings; + + # chunkserver config file + chunkserverCfg = settingsFormat.generate "sfschunkserver.cfg" cfg.chunkserver.settings; + + # generic template for all daemons + systemdService = name: extraConfig: configFile: { + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network.target" + "network-online.target" + ]; + + serviceConfig = { + Type = "forking"; + ExecStart = "${pkgs.saunafs}/bin/sfs${name} -c ${configFile} start"; + ExecStop = "${pkgs.saunafs}/bin/sfs${name} -c ${configFile} stop"; + ExecReload = "${pkgs.saunafs}/bin/sfs${name} -c ${configFile} reload"; + } // extraConfig; + }; + +in +{ + ###### interface + + options = { + services.saunafs = { + masterHost = lib.mkOption { + type = lib.types.str; + default = null; + description = "IP or hostname name of master host."; + }; + + sfsUser = lib.mkOption { + type = lib.types.str; + default = "saunafs"; + description = "Run daemons as user."; + }; + + client.enable = lib.mkEnableOption "Saunafs client"; + + master = { + enable = lib.mkOption { + type = lib.types.bool; + description = '' + Enable Saunafs master daemon. + + You need to run `sfsmaster-init` on a freshly installed master server to + initialize the `DATA_PATH` directory. + ''; + default = false; + }; + + exports = lib.mkOption { + type = with lib.types; listOf str; + default = null; + description = "Paths to exports file (see {manpage}`sfsexports.cfg(5)`)."; + example = lib.literalExpression '' + [ "* / rw,alldirs,admin,maproot=0:0" ]; + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + description = "Whether to automatically open the necessary ports in the firewall."; + default = false; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options.DATA_PATH = lib.mkOption { + type = lib.types.str; + default = "/var/lib/saunafs/master"; + description = "Data storage directory."; + }; + }; + + description = "Contents of config file ({manpage}`sfsmaster.cfg(5)`)."; + }; + }; + + metalogger = { + enable = lib.mkEnableOption "Saunafs metalogger daemon"; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options.DATA_PATH = lib.mkOption { + type = lib.types.str; + default = "/var/lib/saunafs/metalogger"; + description = "Data storage directory"; + }; + }; + + description = "Contents of metalogger config file (see {manpage}`sfsmetalogger.cfg(5)`)."; + }; + }; + + chunkserver = { + enable = lib.mkEnableOption "Saunafs chunkserver daemon"; + + openFirewall = lib.mkOption { + type = lib.types.bool; + description = "Whether to automatically open the necessary ports in the firewall."; + default = false; + }; + + hdds = lib.mkOption { + type = with lib.types; listOf str; + default = null; + + example = lib.literalExpression '' + [ "/mnt/hdd1" ]; + ''; + + description = '' + Mount points to be used by chunkserver for storage (see {manpage}`sfshdd.cfg(5)`). + + Note, that these mount points must writeable by the user defined by the saunafs user. + ''; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = settingsFormat.type; + + options.DATA_PATH = lib.mkOption { + type = lib.types.str; + default = "/var/lib/saunafs/chunkserver"; + description = "Directory for chunck meta data"; + }; + }; + + description = "Contents of chunkserver config file (see {manpage}`sfschunkserver.cfg(5)`)."; + }; + }; + }; + }; + + ###### implementation + + config = + lib.mkIf (cfg.client.enable || cfg.master.enable || cfg.metalogger.enable || cfg.chunkserver.enable) + { + + warnings = [ + (lib.mkIf (cfg.sfsUser == "root") "Running saunafs services as root is not recommended.") + ]; + + # Service settings + services.saunafs = { + master.settings = lib.mkIf cfg.master.enable { + WORKING_USER = cfg.sfsUser; + EXPORTS_FILENAME = toString ( + pkgs.writeText "sfsexports.cfg" (lib.concatStringsSep "\n" cfg.master.exports) + ); + }; + + metalogger.settings = lib.mkIf cfg.metalogger.enable { + WORKING_USER = cfg.sfsUser; + MASTER_HOST = cfg.masterHost; + }; + + chunkserver.settings = lib.mkIf cfg.chunkserver.enable { + WORKING_USER = cfg.sfsUser; + MASTER_HOST = cfg.masterHost; + HDD_CONF_FILENAME = toString ( + pkgs.writeText "sfshdd.cfg" (lib.concatStringsSep "\n" cfg.chunkserver.hdds) + ); + }; + }; + + # Create system user account for daemons + users = + lib.mkIf + (cfg.sfsUser != "root" && (cfg.master.enable || cfg.metalogger.enable || cfg.chunkserver.enable)) + { + users."${cfg.sfsUser}" = { + isSystemUser = true; + description = "saunafs daemon user"; + group = "saunafs"; + }; + groups."${cfg.sfsUser}" = { }; + }; + + environment.systemPackages = + (lib.optional cfg.client.enable pkgs.saunafs) ++ (lib.optional cfg.master.enable initTool); + + networking.firewall.allowedTCPPorts = + (lib.optionals cfg.master.openFirewall [ + 9419 + 9420 + 9421 + ]) + ++ (lib.optional cfg.chunkserver.openFirewall 9422); + + # Ensure storage directories exist + systemd.tmpfiles.rules = + lib.optional cfg.master.enable "d ${cfg.master.settings.DATA_PATH} 0700 ${cfg.sfsUser} ${cfg.sfsUser} -" + ++ lib.optional cfg.metalogger.enable "d ${cfg.metalogger.settings.DATA_PATH} 0700 ${cfg.sfsUser} ${cfg.sfsUser} -" + ++ lib.optional cfg.chunkserver.enable "d ${cfg.chunkserver.settings.DATA_PATH} 0700 ${cfg.sfsUser} ${cfg.sfsUser} -"; + + # Service definitions + systemd.services.sfs-master = lib.mkIf cfg.master.enable ( + systemdService "master" { + TimeoutStartSec = 1800; + TimeoutStopSec = 1800; + Restart = "no"; + } masterCfg + ); + + systemd.services.sfs-metalogger = lib.mkIf cfg.metalogger.enable ( + systemdService "metalogger" { Restart = "on-abort"; } metaloggerCfg + ); + + systemd.services.sfs-chunkserver = lib.mkIf cfg.chunkserver.enable ( + systemdService "chunkserver" { Restart = "on-abort"; } chunkserverCfg + ); + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 0e2a21803c2e..26ae8388f825 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -889,6 +889,7 @@ in { samba-wsdd = handleTest ./samba-wsdd.nix {}; sane = handleTest ./sane.nix {}; sanoid = handleTest ./sanoid.nix {}; + saunafs = handleTest ./saunafs.nix {}; scaphandre = handleTest ./scaphandre.nix {}; schleuder = handleTest ./schleuder.nix {}; scion-freestanding-deployment = handleTest ./scion/freestanding-deployment {}; diff --git a/nixos/tests/saunafs.nix b/nixos/tests/saunafs.nix new file mode 100644 index 000000000000..49d986175716 --- /dev/null +++ b/nixos/tests/saunafs.nix @@ -0,0 +1,122 @@ +import ./make-test-python.nix ( + { pkgs, lib, ... }: + + let + master = + { pkgs, ... }: + { + # data base is stored in memory + # server may crash with default memory size + virtualisation.memorySize = 1024; + + services.saunafs.master = { + enable = true; + openFirewall = true; + exports = [ + "* / rw,alldirs,maproot=0:0" + ]; + }; + }; + + chunkserver = + { pkgs, ... }: + { + virtualisation.emptyDiskImages = [ 4096 ]; + boot.initrd.postDeviceCommands = '' + ${pkgs.e2fsprogs}/bin/mkfs.ext4 -L data /dev/vdb + ''; + + fileSystems = pkgs.lib.mkVMOverride { + "/data" = { + device = "/dev/disk/by-label/data"; + fsType = "ext4"; + }; + }; + + services.saunafs = { + masterHost = "master"; + chunkserver = { + openFirewall = true; + enable = true; + hdds = [ "/data" ]; + + # The test image is too small and gets set to "full" + settings.HDD_LEAVE_SPACE_DEFAULT = "100M"; + }; + }; + }; + + metalogger = + { pkgs, ... }: + { + services.saunafs = { + masterHost = "master"; + metalogger.enable = true; + }; + }; + + client = + { pkgs, lib, ... }: + { + services.saunafs.client.enable = true; + # systemd.tmpfiles.rules = [ "d /sfs 755 root root -" ]; + systemd.network.enable = true; + + # Use networkd to have properly functioning + # network-online.target + networking = { + useDHCP = false; + useNetworkd = true; + }; + + systemd.mounts = [ + { + requires = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "remote-fs.target" ]; + type = "saunafs"; + what = "master:/"; + where = "/sfs"; + } + ]; + }; + + in + { + name = "saunafs"; + + meta.maintainers = [ lib.maintainers.markuskowa ]; + + nodes = { + inherit master metalogger; + chunkserver1 = chunkserver; + chunkserver2 = chunkserver; + client1 = client; + client2 = client; + }; + + testScript = '' + # prepare master server + master.start() + master.wait_for_unit("multi-user.target") + master.succeed("sfsmaster-init") + master.succeed("systemctl restart sfs-master") + master.wait_for_unit("sfs-master.service") + + metalogger.wait_for_unit("sfs-metalogger.service") + + # Setup chunkservers + for chunkserver in [chunkserver1, chunkserver2]: + chunkserver.wait_for_unit("multi-user.target") + chunkserver.succeed("chown saunafs:saunafs /data") + chunkserver.succeed("systemctl restart sfs-chunkserver") + chunkserver.wait_for_unit("sfs-chunkserver.service") + + for client in [client1, client2]: + client.wait_for_unit("multi-user.target") + + client1.succeed("echo test > /sfs/file") + client2.succeed("grep test /sfs/file") + ''; + } +)