Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 159 additions & 45 deletions modules/programs/rclone.nix
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,52 @@ let
replaceIllegalChars = builtins.replaceStrings [ "/" " " "$" ] [ "." "_" "" ];
isUsingSecretProvisioner = name: config ? "${name}" && config."${name}".secrets != { };

# options shared between mounts/serve
mountServeOptions = {
logLevel = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"ERROR"
"NOTICE"
"INFO"
"DEBUG"
]
);
Comment on lines +18 to +25
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for nullOr:

Suggested change
type = lib.types.nullOr (
lib.types.enum [
"ERROR"
"NOTICE"
"INFO"
"DEBUG"
]
);
type = lib.types.enum [
null
"ERROR"
"NOTICE"
"INFO"
"DEBUG"
];

default = null;
example = "INFO";
description = ''
Set the log-level.
See: https://rclone.org/docs/#logging
Comment on lines +29 to +30
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Set the log-level.
See: https://rclone.org/docs/#logging
Set the log level. See <https://rclone.org/docs/#logging> for more.

'';
};
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/rclone";
};
description = ''
An attribute set of option values passed to the command.
To set a boolean option, assign it `true` or `false`. See
<https://nixos.org/manual/nixpkgs/stable/#function-library-lib.cli.toCommandLineShellGNU>
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.
'';
};
};

in
{
meta.maintainers = with lib.maintainers; [ jess ];
Expand Down Expand Up @@ -117,23 +163,6 @@ in
default = true;
};

logLevel = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"ERROR"
"NOTICE"
"INFO"
"DEBUG"
]
);
default = null;
example = "INFO";
description = ''
Set the log-level.
See: https://rclone.org/docs/#logging
'';
};

mountPoint = lib.mkOption {
type = lib.types.str;
default = null;
Expand All @@ -142,34 +171,8 @@ in
'';
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/rclone";
};
description = ''
An attribute set of option values passed to `rclone mount`. To set
a boolean option, assign it `true` or `false`. See
<https://nixos.org/manual/nixpkgs/stable/#function-library-lib.cli.toCommandLineShellGNU>
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.
'';
};
};
}
// mountServeOptions;
}
);
default = { };
Expand Down Expand Up @@ -199,6 +202,67 @@ in
'';

};

serve = lib.mkOption {
type =
with lib.types;
attrsOf (
lib.types.submodule {
options = {
enable = lib.mkEnableOption "serving this path";

protocol = lib.mkOption {
type = lib.types.enum [
"dlna"
"docker"
"ftp"
"http"
"nfs"
"restic"
"s3"
"sftp"
"webdav"
];
description = ''
The protocol to serve this path using.
See: https://rclone.org/commands/rclone_serve
Comment on lines +227 to +228
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The protocol to serve this path using.
See: https://rclone.org/commands/rclone_serve
The protocol to use when serve this path.
See <https://rclone.org/commands/rclone_serve> for more.

'';
example = "http";
};

autoServe = lib.mkEnableOption "automatic serving" // {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe autoStart is a more common nomenclature.

Suggested change
autoServe = lib.mkEnableOption "automatic serving" // {
autoStart = lib.mkEnableOption "automatic serving" // {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the description is pretty unclear. Should be expanded a bit to make it clear it will cause the service to start on login.

default = true;
};
}
// mountServeOptions;
}
);
default = { };
description = ''
An attribute set mapping remote file paths to their corresponding serve configurations.

For each entry, to perform the equivalent of
`rclone serve protocol remote:path/to/files` — as described in the
rclone documentation <https://rclone.org/commands/rclone_serve/> — we create
a key-value pair like this:
`"path/to/files/on/remote" = { ... }`.
'';
example = lib.literalExpression ''
{
"path/to/files" = {
enable = true;
protocol = "http";
options = {
addr = "127.0.0.1:3000";
dir-cache-time = "5000h";
poll-interval = "10s";
umask = "002";
user-agent = "Laptop";
};
};
}
'';
};
};
}
);
Expand Down Expand Up @@ -399,12 +463,62 @@ in
]
)
);

serveServices = lib.listToAttrs (
lib.concatMap
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is completely unclear and definitely needs to be refactored into some more understandable form.

(
{ name, value }:
let
remote-name = name;
remote = value;
in
lib.concatMap (
{ name, value }:
let
serve-path = name;
serve = value;
in
lib.optional serve.enable (
lib.nameValuePair "rclone-serve:${replaceIllegalChars serve-path}@${remote-name}" {
Unit = {
Description = "Rclone protocol serving for ${remote-name}:${serve-path}";
Requires = [ "rclone-config.service" ];
After = [ "rclone-config.service" ];
};

Service = {
Type = "notify";
Environment = lib.optional (serve.logLevel != null) "RCLONE_LOG_LEVEL=${serve.logLevel}";

ExecStart = lib.concatStringsSep " " [
(lib.getExe cfg.package)
"serve"
(lib.escapeShellArg serve.protocol)
(lib.cli.toCommandLineShellGNU { } serve.options)
(lib.escapeShellArg "${remote-name}:${serve-path}")
];
Restart = "on-failure";
};

Install.WantedBy = lib.optional serve.autoServe "default.target";
}
)
) (lib.attrsToList remote.serve)
)
(
lib.pipe cfg.remotes [
lib.attrsToList
(lib.filter (rem: rem.value ? serve))
]
)
);
in
lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
systemd.user.services = lib.mkMerge [
rcloneConfigService
mountServices
serveServices
];
};
}
1 change: 1 addition & 0 deletions tests/integration/standalone/rclone/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ in
./secrets-arbitrary-characters.nix
./no-type.nix
./mount.nix
# ./serve.nix
./shell.nix
./atomic.nix
./write-after.nix
Expand Down
81 changes: 81 additions & 0 deletions tests/integration/standalone/rclone/serve.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{ pkgs, lib, ... }:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it is very nice you added an integration test I would suggest to also add a unit test that verifies that the expected service files are created. Since integration tests are not typically run in CI we would otherwise easily miss regressions.

let
sshKeys = import "${pkgs.path}/nixos/tests/ssh-keys.nix" pkgs;

# https://rclone.org/sftp/#ssh-authentication
keyPem = lib.pipe sshKeys.snakeOilEd25519PrivateKey.text [
lib.trim
(lib.replaceStrings [ "\n" ] [ "\\\\n" ])
];

module = pkgs.writeText "serve-module" ''
{ pkgs, lib, ... }: {
programs.rclone.remotes = {
alices-sftp-remote = {
config = {
type = "sftp";
host = "remote";
user = "alice";
key_pem = "${keyPem}";
known_hosts = "${sshKeys.snakeOilEd25519PublicKey}";
};
serve = {
"/home/alice/files" = {
enable = true;
protocol = "http";
options.addr = "localhost:8080";
};
};
};
};
}
'';
in
{
nodes.remote = {
services.openssh.enable = true;

users.users.alice.openssh.authorizedKeys.keys = [
sshKeys.snakeOilEd25519PublicKey
];
};

script = ''
remote.wait_for_unit("network.target")
remote.wait_for_unit("multi-user.target")

succeed_as_alice(
"mkdir -p /home/alice/.ssh",
"install -m644 ${module} /home/alice/.config/home-manager/test-remote.nix"
)

actual = succeed_as_alice("home-manager switch")
expected = "rclone-config.service"
assert "Starting units: " in actual and expected in actual, \
f"expected home-manager switch to contain {expected}, but got {actual}"

with subtest("Serve a remote over HTTP (sftp)"):
# create files on remote
succeed_as_alice(
"mkdir /home/alice/files",
"touch /home/alice/files/other_file"
"echo serving > /home/alice/files/test.txt",
box=remote
)

# fetch file from server
output = succeed_as_alice(
"curl -s http://localhost:8080/test.txt"
)
expected = "serving"
assert expected in output, \
f"HTTP server response does not contain expected content. Got: {output}"

# verify file listing
output = succeed_as_alice(
"curl -s http://localhost:8080/"
)
assert "other_file" in output, \
f"HTTP directory listing does not contain other_file. Got: {output}"
'';
}