nixos/radicale: add settings option

The radicale version is no longer chosen automatically based on
system.stateVersion because that gave the impression that old versions
are still supported.

Changed files
+193 -159
nixos
doc
manual
release-notes
modules
services
networking
tests
+7
nixos/doc/manual/release-notes/rl-2105.xml
···
The <package>yadm</package> dotfile manager has been updated from 2.x to 3.x, which has new (XDG) default locations for some data/state files. Most yadm commands will fail and print a legacy path warning (which describes how to upgrade/migrate your repository). If you have scripts, daemons, scheduled jobs, shell profiles, etc. that invoke yadm, expect them to fail or misbehave until you perform this migration and prepare accordingly.
</para>
</listitem>
+
<listitem>
+
<para>
+
Instead of determining <option>services.radicale.package</option>
+
automatically based on <option>system.stateVersion</option>, the latest
+
version is always used because old versions are not officially supported.
+
</para>
+
</listitem>
</itemizedlist>
</section>
+113 -36
nixos/modules/services/networking/radicale.nix
···
with lib;
let
-
cfg = config.services.radicale;
-
confFile = pkgs.writeText "radicale.conf" cfg.config;
-
-
defaultPackage = if versionAtLeast config.system.stateVersion "20.09" then {
-
pkg = pkgs.radicale3;
-
text = "pkgs.radicale3";
-
} else if versionAtLeast config.system.stateVersion "17.09" then {
-
pkg = pkgs.radicale2;
-
text = "pkgs.radicale2";
-
} else {
-
pkg = pkgs.radicale1;
-
text = "pkgs.radicale1";
+
format = pkgs.formats.ini {
+
listToValue = concatMapStringsSep ", " (generators.mkValueStringDefault { });
};
-
in
-
{
+
pkg = if isNull cfg.package then
+
pkgs.radicale
+
else
+
cfg.package;
+
+
confFile = if cfg.settings == { } then
+
pkgs.writeText "radicale.conf" cfg.config
+
else
+
format.generate "radicale.conf" cfg.settings;
+
+
rightsFile = format.generate "radicale.rights" cfg.rights;
+
+
in {
+
options.services.radicale = {
+
enable = mkEnableOption "Radicale CalDAV and CardDAV server";
+
+
package = mkOption {
+
description = "Radicale package to use.";
+
# Default cannot be pkgs.radicale because non-null values suppress
+
# warnings about incompatible configuration and storage formats.
+
type = with types; nullOr package // { inherit (package) description; };
+
default = null;
+
defaultText = "pkgs.radicale";
+
};
-
options = {
-
services.radicale.enable = mkOption {
-
type = types.bool;
-
default = false;
+
config = mkOption {
+
type = types.str;
+
default = "";
description = ''
-
Enable Radicale CalDAV and CardDAV server.
+
Radicale configuration, this will set the service
+
configuration file.
+
This option is mutually exclusive with <option>settings</option>.
+
This option is deprecated. Use <option>settings</option> instead.
'';
};
-
services.radicale.package = mkOption {
-
type = types.package;
-
default = defaultPackage.pkg;
-
defaultText = defaultPackage.text;
+
settings = mkOption {
+
type = format.type;
+
default = { };
description = ''
-
Radicale package to use. This defaults to version 1.x if
-
<literal>system.stateVersion &lt; 17.09</literal>, version 2.x if
-
<literal>17.09 ≤ system.stateVersion &lt; 20.09</literal>, and
-
version 3.x otherwise.
+
Configuration for Radicale. See
+
<link xlink:href="https://radicale.org/3.0.html#documentation/configuration" />.
+
This option is mutually exclusive with <option>config</option>.
+
'';
+
example = literalExample ''
+
server = {
+
hosts = [ "0.0.0.0:5232" "[::]:5232" ];
+
};
+
auth = {
+
type = "htpasswd";
+
htpasswd_filename = "/etc/radicale/users";
+
htpasswd_encryption = "bcrypt";
+
};
+
storage = {
+
filesystem_folder = "/var/lib/radicale/collections";
+
};
'';
};
-
services.radicale.config = mkOption {
-
type = types.str;
-
default = "";
+
rights = mkOption {
+
type = format.type;
description = ''
-
Radicale configuration, this will set the service
-
configuration file.
+
Configuration for Radicale's rights file. See
+
<link xlink:href="https://radicale.org/3.0.html#documentation/authentication-and-rights" />.
+
This option only works in conjunction with <option>settings</option>.
+
Setting this will also set <option>settings.rights.type</option> and
+
<option>settings.rights.file</option> to approriate values.
+
'';
+
default = { };
+
example = literalExample ''
+
root = {
+
user = ".+";
+
collection = "";
+
permissions = "R";
+
};
+
principal = {
+
user = ".+";
+
collection = "{user}";
+
permissions = "RW";
+
};
+
calendars = {
+
user = ".+";
+
collection = "{user}/[^/]+";
+
permissions = "rw";
+
};
'';
};
-
services.radicale.extraArgs = mkOption {
+
extraArgs = mkOption {
type = types.listOf types.str;
default = [];
description = "Extra arguments passed to the Radicale daemon.";
···
};
config = mkIf cfg.enable {
-
environment.systemPackages = [ cfg.package ];
+
assertions = [
+
{
+
assertion = cfg.settings == { } || cfg.config == "";
+
message = ''
+
The options services.radicale.config and services.radicale.settings
+
are mutually exclusive.
+
'';
+
}
+
];
+
+
warnings = optional (isNull cfg.package && versionOlder config.system.stateVersion "17.09") ''
+
The configuration and storage formats of your existing Radicale
+
installation might be incompatible with the newest version.
+
For upgrade instructions see
+
https://radicale.org/2.1.html#documentation/migration-from-1xx-to-2xx.
+
Set services.radicale.package to suppress this warning.
+
'' ++ optional (isNull cfg.package && versionOlder config.system.stateVersion "20.09") ''
+
The configuration format of your existing Radicale installation might be
+
incompatible with the newest version. For upgrade instructions see
+
https://github.com/Kozea/Radicale/blob/3.0.6/NEWS.md#upgrade-checklist.
+
Set services.radicale.package to suppress this warning.
+
'' ++ optional (cfg.config != "") ''
+
The option services.radicale.config is deprecated.
+
Use services.radicale.settings instead.
+
'';
+
+
services.radicale.settings.rights = mkIf (cfg.rights != { }) {
+
type = "from_file";
+
file = toString rightsFile;
+
};
+
+
environment.systemPackages = [ pkg ];
users.users.radicale =
{ uid = config.ids.uids.radicale;
···
systemd.services.radicale = {
description = "A Simple Calendar and Contact Server";
after = [ "network.target" ];
+
requires = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = concatStringsSep " " ([
-
"${cfg.package}/bin/radicale" "-C" confFile
+
"${pkg}/bin/radicale" "-C" confFile
] ++ (
map escapeShellArg cfg.extraArgs
));
···
};
};
-
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ];
+
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil dotlambda ];
}
+73 -123
nixos/tests/radicale.nix
···
+
import ./make-test-python.nix ({ lib, pkgs, ... }:
+
let
user = "someuser";
password = "some_password";
-
port = builtins.toString 5232;
+
port = "5232";
+
filesystem_folder = "/data/radicale";
+
+
cli = "${pkgs.calendar-cli}/bin/calendar-cli --caldav-user ${user} --caldav-pass ${password}";
+
in {
+
name = "radicale3";
+
meta.maintainers = with lib.maintainers; [ dotlambda ];
-
common = { pkgs, ... }: {
+
machine = { pkgs, ... }: {
services.radicale = {
enable = true;
-
config = ''
-
[auth]
-
type = htpasswd
-
htpasswd_filename = /etc/radicale/htpasswd
-
htpasswd_encryption = bcrypt
-
-
[storage]
-
filesystem_folder = /tmp/collections
-
'';
+
settings = {
+
auth = {
+
type = "htpasswd";
+
htpasswd_filename = "/etc/radicale/users";
+
htpasswd_encryption = "bcrypt";
+
};
+
storage = {
+
inherit filesystem_folder;
+
hook = "git add -A && (git diff --cached --quiet || git commit -m 'Changes by '%(user)s)";
+
};
+
logging.level = "info";
+
};
+
rights = {
+
principal = {
+
user = ".+";
+
collection = "{user}";
+
permissions = "RW";
+
};
+
calendars = {
+
user = ".+";
+
collection = "{user}/[^/]+";
+
permissions = "rw";
+
};
+
};
};
+
systemd.services.radicale.path = [ pkgs.git ];
+
environment.systemPackages = [ pkgs.git ];
+
systemd.tmpfiles.rules = [ "d ${filesystem_folder} 0750 radicale radicale -" ];
# WARNING: DON'T DO THIS IN PRODUCTION!
# This puts unhashed secrets directly into the Nix store for ease of testing.
-
environment.etc."radicale/htpasswd".source = pkgs.runCommand "htpasswd" {} ''
+
environment.etc."radicale/users".source = pkgs.runCommand "htpasswd" {} ''
${pkgs.apacheHttpd}/bin/htpasswd -bcB "$out" ${user} ${password}
'';
};
-
-
in
-
-
import ./make-test-python.nix ({ lib, ... }@args: {
-
name = "radicale";
-
meta.maintainers = with lib.maintainers; [ aneeshusa infinisil ];
-
-
nodes = rec {
-
radicale = radicale1; # Make the test script read more nicely
-
radicale1 = lib.recursiveUpdate (common args) {
-
nixpkgs.overlays = [
-
(self: super: {
-
radicale1 = super.radicale1.overrideAttrs (oldAttrs: {
-
propagatedBuildInputs = with self.pythonPackages;
-
(oldAttrs.propagatedBuildInputs or []) ++ [ passlib ];
-
});
-
})
-
];
-
system.stateVersion = "17.03";
-
};
-
radicale1_export = lib.recursiveUpdate radicale1 {
-
services.radicale.extraArgs = [
-
"--export-storage" "/tmp/collections-new"
-
];
-
system.stateVersion = "17.03";
-
};
-
radicale2_verify = lib.recursiveUpdate radicale2 {
-
services.radicale.extraArgs = [ "--debug" "--verify-storage" ];
-
system.stateVersion = "17.09";
-
};
-
radicale2 = lib.recursiveUpdate (common args) {
-
system.stateVersion = "17.09";
-
};
-
radicale3 = lib.recursiveUpdate (common args) {
-
system.stateVersion = "20.09";
-
};
-
};
-
-
# This tests whether the web interface is accessible to an authenticated user
-
testScript = { nodes }: let
-
switchToConfig = nodeName: let
-
newSystem = nodes.${nodeName}.config.system.build.toplevel;
-
in "${newSystem}/bin/switch-to-configuration test";
-
in ''
-
with subtest("Check Radicale 1 functionality"):
-
radicale.succeed(
-
"${switchToConfig "radicale1"} >&2"
-
)
-
radicale.wait_for_unit("radicale.service")
-
radicale.wait_for_open_port(${port})
-
radicale.succeed(
-
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
-
)
-
-
with subtest("Export data in Radicale 2 format"):
-
radicale.succeed("systemctl stop radicale")
-
radicale.succeed("ls -al /tmp/collections")
-
radicale.fail("ls -al /tmp/collections-new")
-
-
with subtest("Radicale exits immediately after exporting storage"):
-
radicale.succeed(
-
"${switchToConfig "radicale1_export"} >&2"
-
)
-
radicale.wait_until_fails("systemctl status radicale")
-
radicale.succeed("ls -al /tmp/collections")
-
radicale.succeed("ls -al /tmp/collections-new")
-
-
with subtest("Verify data in Radicale 2 format"):
-
radicale.succeed("rm -r /tmp/collections/${user}")
-
radicale.succeed("mv /tmp/collections-new/collection-root /tmp/collections")
-
radicale.succeed(
-
"${switchToConfig "radicale2_verify"} >&2"
-
)
-
radicale.wait_until_fails("systemctl status radicale")
-
-
(retcode, logs) = radicale.execute("journalctl -u radicale -n 10")
-
assert (
-
retcode == 0 and "Verifying storage" in logs
-
), "Radicale 2 didn't verify storage"
-
assert (
-
"failed" not in logs and "exception" not in logs
-
), "storage verification failed"
-
-
with subtest("Check Radicale 2 functionality"):
-
radicale.succeed(
-
"${switchToConfig "radicale2"} >&2"
-
)
-
radicale.wait_for_unit("radicale.service")
-
radicale.wait_for_open_port(${port})
-
-
(retcode, output) = radicale.execute(
-
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
-
)
-
assert (
-
retcode == 0 and "VCALENDAR" in output
-
), "Could not read calendar from Radicale 2"
+
testScript = ''
+
machine.wait_for_unit("radicale.service")
+
machine.wait_for_open_port(${port})
-
radicale.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
+
machine.succeed("sudo -u radicale git -C ${filesystem_folder} init")
+
machine.succeed(
+
"sudo -u radicale git -C ${filesystem_folder} config --local user.email radicale@example.com"
+
)
+
machine.succeed(
+
"sudo -u radicale git -C ${filesystem_folder} config --local user.name radicale"
+
)
-
with subtest("Check Radicale 3 functionality"):
-
radicale.succeed(
-
"${switchToConfig "radicale3"} >&2"
-
)
-
radicale.wait_for_unit("radicale.service")
-
radicale.wait_for_open_port(${port})
+
with subtest("Test calendar and event creation"):
+
machine.succeed(
+
"${cli} --caldav-url http://localhost:${port}/${user} calendar create cal"
+
)
+
machine.succeed("test -d ${filesystem_folder}/collection-root/${user}/cal")
+
machine.succeed('test -z "$(ls ${filesystem_folder}/collection-root/${user}/cal)"')
+
machine.succeed(
+
"${cli} --caldav-url http://localhost:${port}/${user}/cal calendar add 2021-04-23 testevent"
+
)
+
machine.succeed('test -n "$(ls ${filesystem_folder}/collection-root/${user}/cal)"')
+
(status, stdout) = machine.execute(
+
"sudo -u radicale git -C ${filesystem_folder} log --format=oneline | wc -l"
+
)
+
assert status == 0, "git log failed"
+
assert stdout == "3\n", "there should be exactly 3 commits"
-
(retcode, output) = radicale.execute(
-
"curl --fail http://${user}:${password}@localhost:${port}/someuser/calendar.ics/"
-
)
-
assert (
-
retcode == 0 and "VCALENDAR" in output
-
), "Could not read calendar from Radicale 3"
+
with subtest("Test rights file"):
+
machine.fail(
+
"${cli} --caldav-url http://localhost:${port}/${user} calendar create sub/cal"
+
)
+
machine.fail(
+
"${cli} --caldav-url http://localhost:${port}/otheruser calendar create cal"
+
)
-
radicale.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
-
'';
+
with subtest("Test web interface"):
+
machine.succeed("curl --fail http://${user}:${password}@localhost:${port}/.web/")
+
'';
})