Change:
in btrbk's example configuration. This information can be found in too. The structure of in the NixOS module should conform to the way that the configuration actually works. In this case, the configuration is an ordered list of pairs, but since Nix does not really have this data type, probably a text block like s elsewhere would be most appropriate. The current attribute should be deprecated. I can make a PR for this if the above changes look appropriate.
are always generated under a section. So any settings inside will actually belong to that volume, according to btrbk's "applying to last volume" behavior. This makes the setting generation is truely order independent, and prevent the footgun of btrbk's handling of configurations. The current impl should prevent but not cause the cross-interaction. Is there any practical bug you have met with the current config generation? No. In module system, options are merged by some implicit order when there is no explicit or . This makes approach have a surprising interaction with other definitions of the same option, contradicting your intention.
there is sorting in place so the order is deterministic: for each nesting level, key value pairs go first and nested attributes go last. this makes the nix -btrbk mapping deterministic and conforming to the note you linked: global values go first.
Thanks for explaining this -- I probably should have taken more time to look the implementation already in place. That being said, I did experience a bug when migrating from a working to the NixOS module (or at least it seems like a bug to me). Under , I have: which generates The above Nix config seems to imply that should be applied to whole not just , but this is, in fact, what the generated config produces. I might be missing something here.
Could it be that is the culprit? is being treated like a subsection, which it is, but it is not subsection like /.
:thinking: So can be both a section and a child of section . I think modifying the sorting order to other options ---could fix it. Generally it's better to make the whole typed. But maybe it would take much effort.
Fixing the sort order would be a good occasion to make it clear in the description of the option. About making settings typed: did it but it would be better with a free-form module so that we do not need to adapt the module when new options are to btrbk like rfc 42 advises. (note that I didn't know about free-form modules when I rejected this PR)
The issue here is that whether an option is a "section" or "subsection" matters. We need special cases to give them priority, or it would break silently. That may be worse than "need to adapt for new options".
we can have both with a free-form module
Do you have interest to fix the field sorting and maybe port with free-form modules? If no one claims it in 24h, I'll try it tomorrow.

Discussion:
{ config, pkgs, lib, ... }: let inherit (lib) <ins> concatLists concatMap </ins> concatMapStringsSep concatStringsSep filterAttrs <del> flatten </del> isAttrs <del> isString </del> literalExpression mapAttrs' mapAttrsToList mkIf mkOption optionalString <del> partition typeOf </del> <ins> sort </ins> types ; <del> cfg = config.services.btrbk; sshEnabled = cfg.sshAccess != [ ]; serviceEnabled = cfg.instances != { }; attr2Lines = attr: </del> <ins> # The priority of an option or section. # The configurations format are order-sensitive. Pairs are added as children of # the last sections if possible, otherwise, they start a new section. # We sort them in topological order: # 1. Leaf pairs. # 2. Sections that may contain (1). # 3. Sections that may contain (1) or (2). # 4. Etc. prioOf = { name, value }: if !isAttrs value then 0 # Leaf options. else { target = 1; # Contains: options. subvolume = 2; # Contains: options, target. volume = 3; # Contains: options, target, subvolume. }.${name} or (throw "Unknow section '${name}'"); genConfig' = set: concatStringsSep "n" (genConfig set); genConfig = set: </ins> let <del> pairs = mapAttrsToList (name: value: { inherit name value; }) attr; isSubsection = value: if isAttrs value then true else if isString value then false else throw "invalid type in btrbk config ${typeOf value}"; sortedPairs = partition (x: isSubsection x.value) pairs; </del> <ins> pairs = mapAttrsToList (name: value: { inherit name value; }) set; sortedPairs = sort (a: b: prioOf a < prioOf b) pairs; </ins> in <del> flatten ( # non subsections go first ( map (pair: [ "${pair.name} ${pair.value}" ]) sortedPairs.wrong ) ++ # subsections go last ( map ( pair: mapAttrsToList ( childname: value: [ "${pair.name} ${childname}" ] ++ (map (x: " " + x) (attr2Lines value)) ) pair.value ) sortedPairs.right ) ) ; </del> <ins> concatMap genPair sortedPairs; genSection = sec: secName: value: [ "${sec} ${secName}" ] ++ map (x: " " + x) (genConfig value); genPair = { name, value }: if !isAttrs value then [ "${name} ${value}" ] else concatLists (mapAttrsToList (genSection name) value); </ins> addDefaults = settings: { backend = "btrfs-progs-sudo"; } // settings; <del> mkConfigFile = settings: concatStringsSep "n" (attr2Lines (addDefaults settings)); mkTestedConfigFile = name: settings: let configFile = pkgs.writeText "btrbk-${name}.conf" (mkConfigFile settings); in pkgs.runCommand "btrbk-${name}-tested.conf" { } '' mkdir foo cp ${configFile} $out if (set +o pipefail; ${pkgs.btrbk}/bin/btrbk -c $out ls foo 2>&1 | grep $out); then echo btrbk configuration is invalid cat $out exit 1 fi; </del> <ins> mkConfigFile = name: settings: pkgs.writeTextFile { name = "btrbk-${name}.conf"; text = genConfig' (addDefaults settings); checkPhase = '' set +e ${pkgs.btrbk}/bin/btrbk -c $out dryrun # According to btrbk(1), exit status 2 means parse error # for CLI options or the config file. if [[ $? == 2 ]]; then echo "Btrbk configuration is invalid:" cat $out exit 1 fi set -e </ins> ''; <ins> }; cfg = config.services.btrbk; sshEnabled = cfg.sshAccess != [ ]; serviceEnabled = cfg.instances != { }; </ins> in { meta.maintainers = with lib.maintainers; [ oxalica ];

( name: instance: { name = "btrbk/${name}.conf"; <del> value.source = mkTestedConfigFile name instance.settings; </del> <ins> value.source = mkConfigFile name instance.settings; </ins> } ) cfg.instances;

brscan5 = handleTest ./brscan5.nix {}; btrbk = handleTest ./btrbk.nix {}; btrbk-no-timer = handleTest ./btrbk-no-timer.nix {}; <ins> btrbk-section-order = handleTest ./btrbk-section-order.nix {}; </ins> buildbot = handleTest ./buildbot.nix {}; buildkite-agents = handleTest ./buildkite-agents.nix {}; caddy = handleTest ./caddy.nix {};

<ins> # This tests validates the order of generated sections that may contain # other sections. # When a `volume` section has both `subvolume` and `target` children, # `target` must go before `subvolume`. Otherwise, `target` will become # a child of the last `subvolume` instead of `volume`, due to the # order-sensitive config format. # # Issue: https://github.com/NixOS/nixpkgs/issues/195660 import ./make-test-python.nix ({ lib, pkgs, ... }: { name = "btrbk-section-order"; meta.maintainers = with lib.maintainers; [ oxalica ]; nodes.machine = { ... }: { services.btrbk.instances.local = { onCalendar = null; settings = { timestamp_format = "long"; target."ssh://global-target/".ssh_user = "root"; volume."/btrfs" = { snapshot_dir = "/volume-snapshots"; target."ssh://volume-target/".ssh_user = "root"; subvolume."@subvolume" = { snapshot_dir = "/subvolume-snapshots"; target."ssh://subvolume-target/".ssh_user = "root"; }; }; }; }; }; testScript = '' machine.wait_for_unit("basic.target") got = machine.succeed("cat /etc/btrbk/local.conf") expect = """ backend btrfs-progs-sudo timestamp_format long target ssh://global-target/ ssh_user root volume /btrfs snapshot_dir /volume-snapshots target ssh://volume-target/ ssh_user root subvolume @subvolume snapshot_dir /subvolume-snapshots target ssh://subvolume-target/ ssh_user root """.strip() print(got) assert got == expect ''; }) </ins>