diff --git a/AGENTS.md b/AGENTS.md index 7abd7f3..47de5e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,8 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla Keep `git.alexion.dev:2022` working by proxying that TCP stream through the reverse-proxy guest to Gitea. - Remote deploy helpers that run target-side `sudo` over SSH need a TTY. Use `ssh -t` unless the target command is guaranteed not to prompt for sudo credentials. +- `self.lib.registry.collect { hosts = self.nixosConfigurations; }` reads top-level host `config.registry` declarations only. + Registry declarations inside guest interiors live under `config.containers..config.registry` and do not enter the collected fleet registry unless host-side code also publishes them or the collector is explicitly extended to recurse into guests. - A NixOS module function argument default such as `registry ? config.registry` is not a safe fallback for an unprovided module argument. The module system still queries `_module.args.registry` and fails when it is absent. When an optional special-argument fallback is intentionally needed, bind the module arguments through `@args` and test `args ? ` inside the module body. 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..ee2148f 100644 --- a/guests/copyparty.nix +++ b/guests/copyparty.nix @@ -6,7 +6,6 @@ }: let cfg = config.guests.copyparty; - serviceUid = my.serviceUid "copyparty"; volumeType = lib.types.submodule { options = { @@ -113,7 +112,7 @@ in ${cfg.stateDir} = { hostPath = cfg.statePath; create = { - owner = serviceUid; + owner = config.registry."stable-uid".${config.networking.hostName}.copyparty; 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..2c00390 100644 --- a/lib/registry.nix +++ b/lib/registry.nix @@ -1,16 +1,68 @@ { lib, self }: let inherit (lib) + all + concatLists + concatMapStringsSep + filter filterAttrs + foldl' + hasAttr hasSuffix mapAttrs mapAttrs' + mapAttrsToList mkOption nameValuePair removeSuffix types ; + assertionType = types.submodule { + options = { + assertion = mkOption { + type = types.bool; + description = '' + Whether this registry validation assertion passed. + ''; + }; + + message = mkOption { + type = types.str; + description = '' + Error message shown when this registry validation assertion fails. + ''; + }; + }; + }; + + registryDefinitionModule = { + options = { + type = mkOption { + type = types.raw; + description = '' + NixOS option type for one registry entry. + ''; + }; + + replicate = mkOption { + type = types.bool; + default = false; + description = '' + Whether collected entries are merged and published under every host key. + ''; + }; + + validate = mkOption { + type = types.functionTo (types.listOf assertionType); + default = _entries: [ ]; + description = '' + Validate this registry's flattened collected entries. + ''; + }; + }; + }; + registryName = fileName: removeSuffix ".nix" fileName; registryOption = registry: mkOption { @@ -21,13 +73,96 @@ let ''; }; + mkRegistry = path: + let + evaluated = lib.evalModules { + specialArgs = { inherit lib; }; + modules = [ + registryDefinitionModule + path + ]; + }; + in + { + inherit (evaluated.config) type replicate validate; + }; + definitions = mapAttrs' ( name: type: let path = self + "/registry/${name}"; in - nameValuePair (registryName name) (import path { inherit lib; }) + nameValuePair (registryName name) (mkRegistry path) ) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry"))); + + flattenRegistryEntries = registryName: data: + concatLists ( + mapAttrsToList ( + host: entries: + mapAttrsToList (entry: value: { + path = "registry.${registryName}.${host}.${entry}"; + inherit host entry value; + }) entries + ) data + ); + + 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 = data: + concatLists ( + mapAttrsToList ( + registryName: registry: + registry.validate (flattenRegistryEntries registryName data.${registryName}) + ) definitions + ); + + assertValid = data: + let + assertions = validationAssertions data; + failures = filter (assertion: !assertion.assertion) assertions; + in + assert lib.assertMsg (all (assertion: assertion.assertion) assertions) ( + concatMapStringsSep "\n" (assertion: assertion.message) failures + ); + data; + join = registry: host: entry: filterAttrs (_name: value: value != null) ( mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions @@ -35,10 +170,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 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..d8eae8e 100644 --- a/modules/services/actualbudget.nix +++ b/modules/services/actualbudget.nix @@ -1,7 +1,6 @@ { config, lib, - my, ... }: let @@ -29,12 +28,6 @@ in description = "User account that runs Actual Budget."; }; - uid = lib.mkOption { - type = lib.types.ints.positive; - default = my.serviceUid "actualbudget"; - description = "Stable uid for the Actual Budget service user."; - }; - group = lib.mkOption { type = lib.types.str; default = "storage"; @@ -43,21 +36,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 = config.registry."stable-uid".${config.networking.hostName}.actualbudget; + 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..3ce41ff 100644 --- a/modules/services/copyparty.nix +++ b/modules/services/copyparty.nix @@ -1,7 +1,6 @@ { config, lib, - my, pkgs, ... }: @@ -102,12 +101,6 @@ in description = "User account that runs Copyparty."; }; - uid = lib.mkOption { - type = lib.types.ints.positive; - default = my.serviceUid "copyparty"; - description = "Stable uid for the Copyparty service user."; - }; - group = lib.mkOption { type = lib.types.str; default = "storage"; @@ -148,54 +141,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 = config.registry."stable-uid".${config.networking.hostName}.copyparty; + 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/address.nix b/registry/address.nix index dab4003..8a6fd91 100644 --- a/registry/address.nix +++ b/registry/address.nix @@ -1,4 +1,4 @@ -{ lib }: +{ lib, ... }: { type = lib.types.str; } diff --git a/registry/route.nix b/registry/route.nix index 63c7df4..a07f9dc 100644 --- a/registry/route.nix +++ b/registry/route.nix @@ -1,4 +1,4 @@ -{ lib }: +{ lib, ... }: { type = lib.types.submodule { options = { diff --git a/registry/shell-shortcut.nix b/registry/shell-shortcut.nix index 704e15c..a43b028 100644 --- a/registry/shell-shortcut.nix +++ b/registry/shell-shortcut.nix @@ -1,4 +1,4 @@ -{ lib }: +{ lib, ... }: let entryType = lib.types.submodule { options = { diff --git a/registry/stable-uid.nix b/registry/stable-uid.nix new file mode 100644 index 0000000..bb29bd5 --- /dev/null +++ b/registry/stable-uid.nix @@ -0,0 +1,31 @@ +{ lib, ... }: +let + inherit (lib) + filterAttrs + groupBy + mapAttrs + unique + ; + + serviceNamesByUid = entries: + mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) ( + groupBy (entry: toString entry.value) entries + ); + + duplicateUids = entries: + filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries); + +in +{ + # Next stable UID: 20003 + replicate = true; + + type = lib.types.ints.positive; + + validate = entries: [ + { + assertion = duplicateUids entries == { }; + message = "Stable UID allocations must be unique."; + } + ]; +}