diff --git a/flake.nix b/flake.nix index d3112d8..4f02d49 100644 --- a/flake.nix +++ b/flake.nix @@ -102,8 +102,6 @@ checks.x86_64-linux = let pkgs = nixpkgs.legacyPackages.x86_64-linux; - serviceIdentities = import ./lib/service-identities.nix { inherit lib; }; - serviceIdentityTestsPass = lib.all (result: result) (lib.attrValues serviceIdentities.tests); in (lib.mapAttrs ( name: host: @@ -113,11 +111,6 @@ throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}" ) self.nixosConfigurations) // { - service-identities = pkgs.runCommand "service-identities-check" { } '' - ${lib.optionalString (!serviceIdentityTestsPass) "exit 1"} - touch $out - ''; - copyparty-reverse-proxy-trust-config = let pikachuConfig = self.nixosConfigurations.pikachu.config; @@ -128,6 +121,7 @@ system = "x86_64-linux"; specialArgs.my = self.lib; modules = [ + self.lib.registry.module ./modules/services/copyparty.nix { modules.services.copyparty = { @@ -166,6 +160,7 @@ system = "x86_64-linux"; specialArgs.my = self.lib; modules = [ + self.lib.registry.module ./modules/services/copyparty.nix { modules.services.copyparty = { diff --git a/guests/copyparty.nix b/guests/copyparty.nix index 4d5b7fb..7e0035b 100644 --- a/guests/copyparty.nix +++ b/guests/copyparty.nix @@ -6,7 +6,7 @@ }: let cfg = config.guests.copyparty; - serviceUid = my.serviceUid "copyparty"; + stableUid = config.registry."stable-uid".${config.networking.hostName}.copyparty; volumeType = lib.types.submodule { options = { @@ -113,7 +113,7 @@ in ${cfg.stateDir} = { hostPath = cfg.statePath; create = { - owner = serviceUid; + owner = stableUid; group = "storage"; mode = "0770"; }; diff --git a/lib/default.nix b/lib/default.nix index fc4b110..94b1e88 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -13,7 +13,6 @@ let mapAttrsToList ; - serviceIdentityRegistry = import ./service-identities.nix { inherit lib; }; registry = import ./registry.nix { inherit lib self; }; collectNixFiles = @@ -90,6 +89,4 @@ in inherit (guests) deriveMac guest; - inherit (serviceIdentityRegistry) serviceUid; - serviceIdentities = serviceIdentityRegistry.identities; } diff --git a/lib/registry.nix b/lib/registry.nix index c0724a4..9805051 100644 --- a/lib/registry.nix +++ b/lib/registry.nix @@ -1,10 +1,17 @@ { lib, self }: let inherit (lib) + all + concatLists + concatMapStringsSep + filter filterAttrs + foldl' + hasAttr hasSuffix mapAttrs mapAttrs' + mapAttrsToList mkOption nameValuePair removeSuffix @@ -28,6 +35,67 @@ let in nameValuePair (registryName name) (import path { inherit lib; }) ) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry"))); + + rawDeclarations = registryName: hosts: + mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts; + + replicatedEntries = registryName: raw: + let + entries = concatLists ( + concatLists ( + mapAttrsToList ( + _evalHost: hostDeclarations: + mapAttrsToList ( + hostName: declarations: + mapAttrsToList (entryName: value: { + path = "registry.${registryName}.${hostName}.${entryName}"; + inherit entryName value; + }) declarations + ) hostDeclarations + ) raw + ) + ); + in + foldl' ( + acc: entry: + if hasAttr entry.entryName acc then + if acc.${entry.entryName}.value == entry.value then + acc + else + throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}" + else + acc // { + ${entry.entryName} = entry; + } + ) { } entries; + + replicatedData = registryName: hosts: raw: + let + entries = mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw); + in + mapAttrs (_hostName: _host: entries) hosts; + + validationAssertions = raw: + concatLists ( + mapAttrsToList ( + registryName: registry: + let + validate = registry.validate or (_raw: [ ]); + in + validate raw.${registryName} + ) definitions + ); + + assertValid = raw: value: + let + assertions = validationAssertions raw; + failures = filter (assertion: !assertion.assertion) assertions; + in + assert lib.assertMsg (all (assertion: assertion.assertion) assertions) ( + concatMapStringsSep "\n" (assertion: assertion.message) failures + ); + value; + join = registry: host: entry: filterAttrs (_name: value: value != null) ( mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions @@ -35,10 +103,17 @@ let collectData = { hosts }: - mapAttrs ( - registryName: _registry: - mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts - ) definitions; + let + raw = mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions; + data = mapAttrs ( + registryName: registry: + if registry.replicate or false then + replicatedData registryName hosts raw.${registryName} + else + mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts + ) definitions; + in + assertValid raw data; withOperations = registry: registry diff --git a/lib/service-identities.nix b/lib/service-identities.nix deleted file mode 100644 index d389267..0000000 --- a/lib/service-identities.nix +++ /dev/null @@ -1,59 +0,0 @@ -{ lib }: -let - defaultIdentities = { - actualbudget = 20001; - copyparty = 20002; - }; - - duplicateUidMessage = duplicates: '' - Duplicate stable service UID allocation(s): ${lib.concatStringsSep ", " ( - map (entry: "${toString entry.uid} is assigned to ${lib.concatStringsSep ", " entry.names}") duplicates - )} - ''; - - mk = identities: - let - names = lib.attrNames identities; - namesByUid = lib.foldl' ( - acc: name: - let - uid = toString identities.${name}; - in - acc // { - ${uid} = (acc.${uid} or [ ]) ++ [ name ]; - } - ) { } names; - duplicates = lib.mapAttrsToList (uid: namesForUid: { - uid = lib.toInt uid; - names = namesForUid; - }) (lib.filterAttrs (_uid: namesForUid: builtins.length namesForUid > 1) namesByUid); - checkedIdentities = - assert lib.assertMsg (duplicates == [ ]) (duplicateUidMessage duplicates); - identities; - in - rec { - inherit checkedIdentities; - - serviceUid = name: - if builtins.hasAttr name checkedIdentities then - checkedIdentities.${name} - else - throw "Unknown stable service identity `${name}`. Known identities: ${lib.concatStringsSep ", " (lib.attrNames checkedIdentities)}"; - }; - - default = mk defaultIdentities; - - tests = { - actualbudgetUid = default.serviceUid "actualbudget" == 20001; - copypartyUid = default.serviceUid "copyparty" == 20002; - missingLookupFails = !(builtins.tryEval (default.serviceUid "missing-service")).success; - duplicateUidFails = !(builtins.tryEval (mk { - first = 20003; - second = 20003; - }).checkedIdentities).success; - }; -in -default // { - identities = default.checkedIdentities; - inherit tests; -} diff --git a/modules/services/actualbudget.nix b/modules/services/actualbudget.nix index 45a1d18..0aca484 100644 --- a/modules/services/actualbudget.nix +++ b/modules/services/actualbudget.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -31,8 +30,8 @@ in uid = lib.mkOption { type = lib.types.ints.positive; - default = my.serviceUid "actualbudget"; - description = "Stable uid for the Actual Budget service user."; + default = config.registry."stable-uid".${config.networking.hostName}.actualbudget; + description = "Stable UID for the Actual Budget service user."; }; group = lib.mkOption { @@ -43,21 +42,27 @@ in }; - config = lib.mkIf cfg.enable { - users.users.${cfg.user} = { - isSystemUser = true; - uid = cfg.uid; - group = cfg.group; - }; + config = lib.mkMerge [ + { + registry."stable-uid".${config.networking.hostName}.actualbudget = 20001; + } - services.actual = { - enable = true; - openFirewall = true; - user = cfg.user; - group = cfg.group; - settings = { - inherit (cfg) port dataDir; + (lib.mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + uid = cfg.uid; + group = cfg.group; }; - }; - }; + + services.actual = { + enable = true; + openFirewall = true; + user = cfg.user; + group = cfg.group; + settings = { + inherit (cfg) port dataDir; + }; + }; + }) + ]; } diff --git a/modules/services/copyparty.nix b/modules/services/copyparty.nix index d57174d..b6b4529 100644 --- a/modules/services/copyparty.nix +++ b/modules/services/copyparty.nix @@ -1,7 +1,6 @@ { config, lib, - my, pkgs, ... }: @@ -104,8 +103,8 @@ in uid = lib.mkOption { type = lib.types.ints.positive; - default = my.serviceUid "copyparty"; - description = "Stable uid for the Copyparty service user."; + default = config.registry."stable-uid".${config.networking.hostName}.copyparty; + description = "Stable UID for the Copyparty service user."; }; group = lib.mkOption { @@ -148,54 +147,60 @@ in }; }; - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = cfg.volumes != { }; - message = "modules.services.copyparty.volumes must declare at least one served volume."; - } - { - assertion = !cfg.reverseProxy.enable || cfg.reverseProxy.trustedSources != [ ]; - message = "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled."; - } - ]; + config = lib.mkMerge [ + { + registry."stable-uid".${config.networking.hostName}.copyparty = 20002; + } - users.users.${cfg.user} = { - isSystemUser = true; - uid = cfg.uid; - group = cfg.group; - home = cfg.stateDir; - }; + (lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.volumes != { }; + message = "modules.services.copyparty.volumes must declare at least one served volume."; + } + { + assertion = !cfg.reverseProxy.enable || cfg.reverseProxy.trustedSources != [ ]; + message = "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled."; + } + ]; - systemd.services.copyparty = { - description = "Copyparty file server"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ReloadSignal = "SIGUSR1"; - Type = "simple"; - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.stateDir; - Environment = [ - "HOME=${cfg.stateDir}" - "XDG_CONFIG_HOME=${cfg.stateDir}" - ]; - StateDirectory = "copyparty"; - RuntimeDirectory = "copyparty"; - RuntimeDirectoryMode = "0700"; - LoadCredential = [ "password:${cfg.passwordFile}" ]; - ExecStartPre = configScript; - ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf"; - Restart = "on-failure"; - NoNewPrivileges = true; - PrivateTmp = true; - ProtectSystem = "strict"; - ProtectHome = true; - ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes); + users.users.${cfg.user} = { + isSystemUser = true; + uid = cfg.uid; + group = cfg.group; + home = cfg.stateDir; }; - }; - networking.firewall.allowedTCPPorts = [ cfg.port ]; - }; + systemd.services.copyparty = { + description = "Copyparty file server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ReloadSignal = "SIGUSR1"; + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.stateDir; + Environment = [ + "HOME=${cfg.stateDir}" + "XDG_CONFIG_HOME=${cfg.stateDir}" + ]; + StateDirectory = "copyparty"; + RuntimeDirectory = "copyparty"; + RuntimeDirectoryMode = "0700"; + LoadCredential = [ "password:${cfg.passwordFile}" ]; + ExecStartPre = configScript; + ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf"; + Restart = "on-failure"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes); + }; + }; + + networking.firewall.allowedTCPPorts = [ cfg.port ]; + }) + ]; } diff --git a/registry/stable-uid.nix b/registry/stable-uid.nix new file mode 100644 index 0000000..56b33da --- /dev/null +++ b/registry/stable-uid.nix @@ -0,0 +1,99 @@ +{ lib }: +let + inherit (lib) + concatLists + concatStringsSep + foldl' + groupBy + mapAttrsToList + unique + ; + + declarations = raw: + concatLists ( + mapAttrsToList ( + _evalHost: hostDeclarations: + concatLists ( + mapAttrsToList ( + hostName: entries: + mapAttrsToList (serviceName: uid: { + path = "${hostName}.${serviceName}"; + inherit serviceName uid; + }) entries + ) hostDeclarations + ) + ) raw + ); + + duplicateServices = entries: + let + byService = groupBy (entry: entry.serviceName) entries; + in + foldl' ( + acc: serviceName: + let + serviceEntries = byService.${serviceName}; + uids = unique (map (entry: entry.uid) serviceEntries); + in + if builtins.length uids > 1 then + acc ++ [ { + inherit serviceName uids; + paths = map (entry: entry.path) serviceEntries; + } ] + else + acc + ) [ ] (builtins.attrNames byService); + + duplicateUids = entries: + let + byUid = groupBy (entry: toString entry.uid) entries; + in + foldl' ( + acc: uid: + let + uidEntries = byUid.${uid}; + serviceNames = unique (map (entry: entry.serviceName) uidEntries); + in + if builtins.length serviceNames > 1 then + acc ++ [ { + uid = lib.toInt uid; + inherit serviceNames; + paths = map (entry: entry.path) uidEntries; + } ] + else + acc + ) [ ] (builtins.attrNames byUid); + + renderServiceConflict = conflict: + "${conflict.serviceName} is assigned UIDs ${concatStringsSep ", " (map toString conflict.uids)} by ${concatStringsSep ", " conflict.paths}"; + + renderUidConflict = conflict: + "${toString conflict.uid} is assigned to ${concatStringsSep ", " conflict.serviceNames} by ${concatStringsSep ", " conflict.paths}"; +in +{ + # Next stable UID: 20003 + replicate = true; + + type = lib.types.ints.positive; + + validate = raw: + let + entries = declarations raw; + serviceConflicts = duplicateServices entries; + uidConflicts = duplicateUids entries; + in + [ + { + assertion = serviceConflicts == [ ]; + message = '' + Conflicting stable UID allocation(s): ${concatStringsSep "\n" (map renderServiceConflict serviceConflicts)} + ''; + } + { + assertion = uidConflicts == [ ]; + message = '' + Duplicate stable UID allocation(s): ${concatStringsSep "\n" (map renderUidConflict uidConflicts)} + ''; + } + ]; +}