diff --git a/lib/distributed-service/inventory-adapter.nix b/lib/distributed-service/inventory-adapter.nix index 9ee7e2782..9276029bb 100644 --- a/lib/distributed-service/inventory-adapter.nix +++ b/lib/distributed-service/inventory-adapter.nix @@ -153,6 +153,7 @@ let class = "clan.service"; modules = [ + ./service-module.nix # Import the resolved module (builtins.head instances).instance.resolvedModule ] diff --git a/lib/distributed-service/service-module.nix b/lib/distributed-service/service-module.nix new file mode 100644 index 000000000..86da40818 --- /dev/null +++ b/lib/distributed-service/service-module.nix @@ -0,0 +1,507 @@ +{ lib, config, ... }: +let + inherit (lib) mkOption types; + inherit (types) attrsWith submoduleWith; + + # TODO: + # Remove once this gets merged upstream; performs in O(n*log(n) instead of O(n^2)) + # https://github.com/NixOS/nixpkgs/pull/355616/files + uniqueStrings = list: builtins.attrNames (builtins.groupBy lib.id list); + + checkInstanceRoles = + instanceName: instanceRoles: + let + unmatchedRoles = lib.filter (roleName: !lib.elem roleName (lib.attrNames config.roles)) ( + lib.attrNames instanceRoles + ); + in + if unmatchedRoles == [ ] then + true + else + throw '' + inventory instance: 'instances.${instanceName}' defines the following roles: + ${builtins.toJSON unmatchedRoles} + + But the clan-service module '${config.manifest.name}' defines roles: + ${builtins.toJSON (lib.attrNames config.roles)} + ''; + + # checkInstanceSettings = + # instanceName: instanceSettings: + # let + # unmatchedRoles = 1; + # in + # unmatchedRoles; + + /** + Merges the role- and machine-settings using the role interface + + Arguments: + + - roleName: The name of the role + - instanceName: The name of the instance + - settings: The settings of the machine. Leave empty to get the role settings + + Returns: evalModules result + + The caller is responsible to use .config or .extendModules + */ + # TODO: evaluate against the role.settings statically and use extendModules to get the machineSettings + # Doing this might improve performance + evalMachineSettings = + { + roleName, + instanceName, + machineName ? null, + settings, + }: + lib.evalModules { + # Prefix for better error reporting + # This prints the path where the option should be defined rather than the plain path within settings + # "The option `instances.foo.roles.server.machines.test.settings.<>' was accessed but has no value defined. Try setting the option." + prefix = + [ + "instances" + instanceName + "roles" + roleName + ] + ++ (lib.optionals (machineName != null) [ + "machines" + machineName + ]) + ++ [ "settings" ]; + + # This may lead to better error reporting + # And catch errors if anyone tried to import i.e. a nixosConfiguration + # Set some class: i.e "network.server.settings" + class = lib.concatStringsSep "." [ + config.manifest.name + roleName + "settings" + ]; + + modules = [ + (lib.setDefaultModuleLocation "Via clan.service module: roles.${roleName}.interface" + config.roles.${roleName}.interface + ) + (lib.setDefaultModuleLocation "inventory.instances.${instanceName}.roles.${roleName}.settings" + config.instances.${instanceName}.roles.${roleName}.settings + ) + settings + # Dont set the module location here + # This should already be set by the tags resolver + # config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.settings + ]; + }; + + /** + Makes a module extensible + returning its config + and making it extensible via '__functor' polymorphism + + Example: + + ```nix-repl + res = makeExtensibleConfig (evalModules { options.foo = mkOption { default = 42; };) + res + => + { + foo = 42; + _functor = ; + } + + # This allows to override using mkDefault, mkForce, etc. + res { foo = 100; } + => + { + foo = 100; + _functor = ; + } + ``` + */ + makeExtensibleConfig = + f: args: + let + makeModuleExtensible = + eval: + eval.config + // { + __functor = _self: m: makeModuleExtensible (eval.extendModules { modules = lib.toList m; }); + }; + in + makeModuleExtensible (f args); + + /** + Apply the settings to the instance + + Takes a [ServiceInstance] :: { roles :: { roleName :: { machines :: { machineName :: { settings :: { ... } } } } } } + Returns the same object but evaluates the settings against the interface. + + We need this because 'perMachine' shouldn't gain access the raw deferred module. + */ + applySettings = + instanceName: instance: + lib.mapAttrs (roleName: role: { + machines = lib.mapAttrs (machineName: v: { + # TODO: evaluate the settings against the interface + # settings = (evalMachineSettings { inherit roleName instanceName; inherit (v) settings; }).config; + settings = ( + makeExtensibleConfig evalMachineSettings { + inherit roleName instanceName machineName; + inherit (v) settings; + } + ); + }) role.machines; + # TODO: evaluate the settings against the interface + settings = ( + makeExtensibleConfig evalMachineSettings { + inherit roleName instanceName; + inherit (role) settings; + } + ); + }) instance.roles; +in +{ + options = { + instances = mkOption { + default = throw '' + The clan service module ${config.manifest.name} doesn't define any instances. + + Did you forget to create instances via 'inventory.instances' ? + ''; + + type = attrsWith { + placeholder = "instanceName"; + elemType = submoduleWith { + modules = [ + ( + { name, ... }: + { + # options.settings = mkOption { + # description = "settings of 'instance': ${name}"; + # default = {}; + # apply = v: lib.seq (checkInstanceSettings name v) v; + # }; + options.roles = mkOption { + default = throw '' + Instance '${name}' of service '${config.manifest.name}' mut define members via 'roles'. + + To include a machine: + 'instances.${name}.roles..machines.' must be set. + ''; + type = attrsWith { + placeholder = "roleName"; + elemType = submoduleWith { + modules = [ + ( + { ... }: + { + # instances.{instanceName}.roles.{roleName}.machines + options.machines = mkOption { + type = attrsWith { + placeholder = "machineName"; + elemType = submoduleWith { + modules = [ + (m: { + # TODO: make this a deferred module? + options.settings = mkOption { + description = "Settings of '${name}-machine': ${m.name}."; + default = { }; + }; + }) + ]; + }; + }; + }; + + # instances.{instanceName}.roles.{roleName}.settings + # options._settings = mkOption { }; + # options._settingsViaTags = mkOption { }; + # A deferred module that combines _settingsViaTags with _settings + options.settings = mkOption { + description = "Settings of 'role': ${name}"; + default = { }; + }; + } + ) + ]; + }; + }; + apply = v: lib.seq (checkInstanceRoles name v) v; + }; + } + ) + ]; + }; + }; + }; + # placeholder = "roleName"; + + manifest = mkOption { + type = submoduleWith { + modules = [ + { + options = { + name = mkOption { + type = types.str; + }; + }; + } + ]; + }; + }; + roles = mkOption { + default = throw '' + Role behavior of service '${config.manifest.name}' must be defined. + A 'clan.service' module should always define its behavior via 'roles' + --- + To add the role: + `roles.client = {}` + + To define multiple instance behavior: + `roles.client.perInstance = { ... }: {}` + ''; + type = attrsWith { + placeholder = "roleName"; + elemType = submoduleWith { + modules = [ + ( + { name, ... }: + let + roleName = name; + in + { + options.interface = mkOption { + type = types.deferredModule; + # TODO: Default to an empty module + # need to test that an the empty module can be evaluated to empty settings + default = { }; + }; + options.perInstance = mkOption { + type = types.deferredModuleWith { + staticModules = [ + # Common output format + # As described by adr + # { nixosModule, services, ... } + ( + { ... }: + { + options.nixosModule = mkOption { default = { }; }; + options.services = mkOption { + type = attrsWith { + placeholder = "serviceName"; + elemType = submoduleWith { + modules = [ ./service-module.nix ]; + }; + }; + default = { }; + }; + } + ) + ]; + }; + default = { }; + apply = + /** + This apply transforms the module into a function that takes arguments and returns an evaluated module + The arguments of the function are determined by its scope: + -> 'perInstance' maps over all instances and over all machines hence it takes 'instanceName' and 'machineName' as iterator arguments + */ + v: instanceName: machineName: + (lib.evalModules { + specialArgs = { + inherit instanceName; + machine = { + name = machineName; + roles = applySettings instanceName config.instances.${instanceName}; + }; + settings = ( + makeExtensibleConfig evalMachineSettings { + inherit roleName instanceName machineName; + settings = + config.instances.${instanceName}.roles.${roleName}.machines.${machineName}.settings or { }; + } + ); + }; + modules = [ v ]; + }).config; + }; + } + ) + ]; + }; + }; + }; + + perMachine = mkOption { + type = types.deferredModuleWith { + staticModules = [ + # Common output format + # As described by adr + # { nixosModule, services, ... } + ( + { ... }: + { + options.nixosModule = mkOption { default = { }; }; + options.services = mkOption { + type = attrsWith { + placeholder = "serviceName"; + elemType = submoduleWith { + modules = [ ./service-module.nix ]; + }; + }; + default = { }; + }; + } + ) + ]; + }; + default = { }; + apply = + v: machineName: machineScope: + (lib.evalModules { + specialArgs = { + /** + This apply transforms the module into a function that takes arguments and returns an evaluated module + The arguments of the function are determined by its scope: + -> 'perMachine' maps over all machines of a service 'machineName' and a helper 'scope' (some aggregated attributes) as iterator arguments + The 'scope' attribute is used to collect the 'roles' of all 'instances' where the machine is part of and inject both into the specialArgs + */ + machine = { + name = machineName; + roles = + let + collectRoles = + instances: + lib.foldlAttrs ( + r: _instanceName: instance: + r + ++ lib.foldlAttrs ( + r2: roleName: _role: + r2 ++ [ roleName ] + ) [ ] instance.roles + ) [ ] instances; + in + uniqueStrings (collectRoles machineScope.instances); + }; + inherit (machineScope) instances; + + # There are no machine settings. + # Settings are always role specific, having settings that apply to a machine globally would mean to merge all role and all instance settings into a single module. + # But that will likely cause conflicts because it is inherently wrong. + settings = throw '' + 'perMachine' doesn't have a 'settings' argument. + + Alternatives: + - 'instances..roles..settings' should be used instead. + - 'instances..roles..machines..settings' should be used instead. + + If that is insufficient, you might also consider using 'roles..perInstance' instead of 'perMachine'. + ''; + }; + + modules = [ v ]; + }).config; + }; + # --- + # Place the result in _module.result to mark them as "internal" and discourage usage/overrides + # + # --- + # Intermediate result by mapping over the 'roles', 'instances', and 'machines'. + # During this step the 'perMachine' and 'perInstance' are applied. + # The result-set for a single machine can then be found by collecting all 'nixosModules' recursively. + result.allRoles = mkOption { + readOnly = true; + default = lib.mapAttrs (roleName: roleCfg: { + allInstances = lib.mapAttrs (instanceName: instanceCfg: { + allMachines = lib.mapAttrs ( + machineName: _machineCfg: roleCfg.perInstance instanceName machineName + ) instanceCfg.roles.${roleName}.machines or { }; + }) config.instances; + }) config.roles; + }; + + result.allMachines = mkOption { + readOnly = true; + default = + let + collectMachinesFromInstance = + instance: + uniqueStrings ( + lib.foldlAttrs ( + acc: _roleName: role: + acc ++ (lib.attrNames role.machines) + ) [ ] instance.roles + ); + # The service machines are defined by collecting all instance machines + serviceMachines = lib.foldlAttrs ( + acc: instanceName: instance: + acc + // lib.genAttrs (collectMachinesFromInstance instance) (machineName: + # Store information why this machine is part of the service + # MachineOrigin :: { instances :: [ string ]; } + { + # Helper attribute to + instances = [ instanceName ] ++ acc.${machineName}.instances or [ ]; + # All roles of the machine ? + roles = lib.foldlAttrs ( + acc2: roleName: role: + if builtins.elem machineName (lib.attrNames role.machines) then acc2 ++ [ roleName ] else acc2 + ) [ ] instance.roles; + }) + ) { } config.instances; + + allMachines = lib.mapAttrs (_machineName: MachineOrigin: { + # Filter out instances of which the machine is not part of + instances = lib.mapAttrs (_n: v: { roles = v; }) ( + lib.filterAttrs (instanceName: _: builtins.elem instanceName MachineOrigin.instances) ( + # Instances with evaluated settings + lib.mapAttrs applySettings config.instances + ) + ); + }) serviceMachines; + in + # allMachines; + lib.mapAttrs config.perMachine allMachines; + }; + + result.final = mkOption { + readOnly = true; + default = lib.mapAttrs ( + machineName: machineResult: + let + # config.result.allRoles.client.allInstances.bar.allMachines.test + # instanceResults = config.result.allRoles.client.allInstances.bar.allMachines.${machineName}; + instanceResults = lib.foldlAttrs ( + acc: roleName: role: + acc + ++ lib.foldlAttrs ( + acc: instanceName: instance: + if instance.allMachines.${machineName}.nixosModule or { } != { } then + acc + ++ [ + (lib.setDefaultModuleLocation + "Via instances.${instanceName}.roles.${roleName}.machines.${machineName}" + instance.allMachines.${machineName}.nixosModule + ) + ] + else + acc + ) [ ] role.allInstances + ) [ ] config.result.allRoles; + in + { + inherit instanceResults; + nixosModule = { + imports = [ + # For error backtracing. This module was produced by the 'perMachine' function + (lib.setDefaultModuleLocation "via perMachine" machineResult.nixosModule) + ] ++ instanceResults; + }; + } + ) config.result.allMachines; + + }; + }; +}