diff --git a/modules/programs/rclone.nix b/modules/programs/rclone.nix index 2f789a278..910828664 100644 --- a/modules/programs/rclone.nix +++ b/modules/programs/rclone.nix @@ -9,6 +9,7 @@ let cfg = config.programs.rclone; iniFormat = pkgs.formats.ini { }; + replaceSlashes = builtins.replaceStrings [ "/" ] [ "." ]; in { @@ -87,6 +88,80 @@ in api_key = config.age.secrets.api-key.path; }''; }; + + mounts = lib.mkOption { + type = + with lib.types; + attrsOf ( + lib.types.submodule { + options = { + enable = lib.mkEnableOption "this mount"; + + mountPoint = lib.mkOption { + type = lib.types.str; + default = null; + description = '' + A local file path specifying the location of the mount point. + ''; + example = "/home/alice/my-remote"; + }; + + options = lib.mkOption { + type = + with lib.types; + attrsOf ( + nullOr (oneOf [ + bool + int + float + str + ]) + ); + default = { }; + apply = lib.mergeAttrs { + vfs-cache-mode = "full"; + cache-dir = "%C"; + }; + description = '' + An attribute set of option values passed to `rclone mount`. To set + a boolean option, assign it `true` or `false`. See + + for more details on the format. + + Some caching options are set by default, namely `vfs-cache-mode = "full"` + and `cache-dir`. These can be overridden if desired. + ''; + }; + }; + } + ); + default = { }; + description = '' + An attribute set mapping remote file paths to their corresponding mount + point configurations. + + For each entry, to perform the equivalent of + `rclone mount remote:path/to/files /path/to/local/mount` — as described in the + rclone documentation — we create + a key-value pair like this: + `"path/to/files/on/remote" = { ... }`. + ''; + example = lib.literalExpression '' + { + "path/to/files" = { + enable = true; + mountPoint = "/home/alice/rclone-mount"; + options = { + dir-cache-time = "5000h"; + poll-interval = "10s"; + umask = "002"; + user-agent = "Laptop"; + }; + }; + } + ''; + + }; }; } ); @@ -164,6 +239,56 @@ in '' ); }; + + systemd.user.services = lib.listToAttrs ( + lib.concatMap + ( + { name, value }: + let + remote-name = name; + remote = value; + in + lib.concatMap ( + { name, value }: + let + mount-path = name; + mount = value; + in + [ + (lib.nameValuePair "rclone-mount:${replaceSlashes mount-path}@${remote-name}" { + Unit = { + Description = "Rclone FUSE daemon for ${remote-name}:${mount-path}"; + }; + + Service = { + Environment = [ + # fusermount/fusermount3 + "PATH=/run/wrappers/bin" + ]; + ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p ${mount.mountPoint}"; + ExecStart = lib.concatStringsSep " " [ + (lib.getExe cfg.package) + "mount" + "-vv" + (lib.cli.toGNUCommandLineShell { } mount.options) + "${remote-name}:${mount-path}" + "${mount.mountPoint}" + ]; + Restart = "on-failure"; + }; + + Install.WantedBy = [ "default.target" ]; + }) + ] + ) (lib.attrsToList remote.mounts) + ) + ( + lib.pipe cfg.remotes [ + lib.attrsToList + (lib.filter (rem: rem.value ? mounts)) + ] + ) + ); }; meta.maintainers = with lib.hm.maintainers; [ jess ]; diff --git a/tests/integration/standalone/rclone/default.nix b/tests/integration/standalone/rclone/default.nix index cca390578..fbd4b979a 100644 --- a/tests/integration/standalone/rclone/default.nix +++ b/tests/integration/standalone/rclone/default.nix @@ -1,20 +1,36 @@ { pkgs, ... }: +let + sshKeys = import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs; + + baseMachine = extend: { + imports = [ + "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" + extend + ]; + virtualisation.memorySize = 2048; + users.users.alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + }; +in { name = "rclone"; - nodes.machine = - { ... }: - { - imports = [ "${pkgs.path}/nixos/modules/installer/cd-dvd/channel.nix" ]; - virtualisation.memorySize = 2048; - users.users.alice = { - isNormalUser = true; - description = "Alice Foobar"; - password = "foobar"; - uid = 1000; - }; + nodes = { + machine = baseMachine { }; + + remote = baseMachine { + services.openssh.enable = true; + + users.users.alice.openssh.authorizedKeys.keys = [ + sshKeys.snakeOilEd25519PublicKey + ]; }; + }; testScript = '' start_all() @@ -36,8 +52,13 @@ def alice_cmd(cmd): return f"su -l alice --shell /bin/sh -c $'export XDG_RUNTIME_DIR=/run/user/$UID ; {cmd}'" - def succeed_as_alice(*cmds): - return machine.succeed(*map(alice_cmd,cmds)) + def succeed_as_alice(*cmds, box=machine): + return box.succeed(*map(alice_cmd,cmds)) + + def systemctl_succeed_as_alice(cmd): + status, out = machine.systemctl(cmd, "alice") + assert status == 0, f"failed to run systemctl {cmd}" + return out def fail_as_alice(*cmds): return machine.fail(*map(alice_cmd,cmds)) @@ -101,6 +122,65 @@ # TODO: verify correct activation order with the agenix and sops hm modules + remote.wait_for_unit("network.target") + remote.wait_for_unit("multi-user.target") + + with subtest("Mount a remote (sftp)"): + # https://rclone.org/commands/rclone_mount/#vfs-directory-cache + # Sending a SIGHUP evicts every dcache entry + def clear_vfs_dcache(): + svc_name = "rclone-mount:.home.alice.files@alices-sftp-remote.service" + succeed_as_alice(f"kill -s HUP $(systemctl --user show -p MainPID --value {svc_name})") + succeed_as_alice( + "sync", + "sleep 5", + box=remote + ) + + succeed_as_alice( + "mkdir -p /home/alice/.ssh", + "install -m644 ${./mount.nix} /home/alice/.config/home-manager/test-remote.nix" + ) + + actual = succeed_as_alice("home-manager switch") + expected = "Activating createRcloneConfig" + assert expected in actual, \ + f"expected home-manager switch to contain {expected}, but got {actual}" + + # remote -> machine + succeed_as_alice( + "mkdir /home/alice/files", + "touch /home/alice/files/test", + "echo started > /home/alice/files/log", + box=remote + ) + + succeed_as_alice("ls /home/alice/remote-files/test") + + test_log = succeed_as_alice("cat /home/alice/remote-files/log") + expected = "started"; + assert expected in test_log, \ + f"Mounted file does not have expected contents. Expected {test_log} to contain \"{expected}\"" + + # machine -> remote + succeed_as_alice( + "touch /home/alice/remote-files/new-file", + "echo testing this works both ways! >> /home/alice/remote-files/log", + ) + + clear_vfs_dcache() + + succeed_as_alice("ls /home/alice/files/new-file", box=remote) + + test_log = succeed_as_alice("cat /home/alice/files/log", box=remote) + expected = "testing this works both ways!" + assert expected in test_log, \ + f"Mounted file does not have expected contents. Expected {test_log} to contain \"{expected}\"" + + expected = "started" + assert expected in test_log, \ + f"Mounted file does not have expected contents. Expected {test_log} to contain \"{expected}\"" + logout_alice() ''; } diff --git a/tests/integration/standalone/rclone/mount.nix b/tests/integration/standalone/rclone/mount.nix new file mode 100644 index 000000000..1d043f945 --- /dev/null +++ b/tests/integration/standalone/rclone/mount.nix @@ -0,0 +1,27 @@ +{ pkgs, lib, ... }: +let + sshKeys = import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs; +in +{ + programs.rclone.remotes = { + alices-sftp-remote = { + config = { + type = "sftp"; + host = "remote"; + user = "alice"; + # https://rclone.org/sftp/#ssh-authentication + key_pem = lib.pipe sshKeys.snakeOilEd25519PrivateKey.text [ + lib.trim + (lib.replaceStrings [ "\n" ] [ "\\n" ]) + ]; + known_hosts = sshKeys.snakeOilEd25519PublicKey; + }; + mounts = { + "/home/alice/files" = { + enable = true; + mountPoint = "/home/alice/remote-files"; + }; + }; + }; + }; +}