diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 5eed4c7a8..299a4f79a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -80,7 +80,7 @@ nav: - macOS: guides/macos.md - Reference: - Overview: reference/index.md - - Clan Services: + - Services: - Overview: reference/clanServices/index.md - reference/clanServices/admin.md - reference/clanServices/auto-upgrade.md @@ -93,7 +93,8 @@ nav: - reference/clanServices/hello-world.md - reference/clanServices/wifi.md - reference/clanServices/zerotier.md - - Clan Modules: + - Interface for making Services: reference/clanServices/clan-service-author-interface.md + - Modules: - Overview: reference/clanModules/index.md - reference/clanModules/frontmatter/index.md # TODO: display the docs of the clan.service modules diff --git a/docs/nix/flake-module.nix b/docs/nix/flake-module.nix index bfba3e72b..7b7b1fb67 100644 --- a/docs/nix/flake-module.nix +++ b/docs/nix/flake-module.nix @@ -90,6 +90,7 @@ export CLAN_MODULES_VIA_ROLES=${clanModulesViaRoles} export CLAN_MODULES_VIA_SERVICE=${clanModulesViaService} export CLAN_MODULES_VIA_NIX=${clanModulesViaNix} + export CLAN_SERVICE_INTERFACE=${self'.legacyPackages.clan-service-module-interface}/share/doc/nixos/options.json # Frontmatter format for clanModules export CLAN_MODULES_FRONTMATTER_DOCS=${clanModulesFrontmatter}/share/doc/nixos/options.json diff --git a/docs/nix/render_options/__init__.py b/docs/nix/render_options/__init__.py index b66de4f13..ae80b0635 100644 --- a/docs/nix/render_options/__init__.py +++ b/docs/nix/render_options/__init__.py @@ -49,6 +49,10 @@ CLAN_MODULES_VIA_NIX = os.environ.get("CLAN_MODULES_VIA_NIX") # Some modules can be imported via inventory CLAN_MODULES_VIA_ROLES = os.environ.get("CLAN_MODULES_VIA_ROLES") +# Options how to author clan.modules +# perInstance, perMachine, ... +CLAN_SERVICE_INTERFACE = os.environ.get("CLAN_SERVICE_INTERFACE") + CLAN_MODULES_VIA_SERVICE = os.environ.get("CLAN_MODULES_VIA_SERVICE") OUT = os.environ.get("out") @@ -309,7 +313,7 @@ def produce_clan_core_docs() -> None: core_outputs[indexfile] += """!!! info "Submodules"\n""" for submodule_name, split_options in split.items(): - root = options_to_tree(split_options, debug=True) + root = options_to_tree(split_options) module = root.suboptions[0] module_type = module.info.get("type") if module_type is not None and "submodule" not in module_type: @@ -798,6 +802,44 @@ def split_options_by_root(options: dict[str, Any]) -> dict[str, dict[str, Any]]: return res +def produce_clan_service_author_docs() -> None: + if not CLAN_SERVICE_INTERFACE: + msg = f"Environment variables are not set correctly: CLAN_SERVICE_INTERFACE={CLAN_SERVICE_INTERFACE}. Expected a path to the optionsJSON" + raise ClanError(msg) + + if not OUT: + msg = f"Environment variables are not set correctly: $out={OUT}" + raise ClanError(msg) + + output = """ +This document describes the structure and configurable attributes of a `clan.service` module. + +Typically needed by module authors to define roles, behavior and metadata for distributed services. + +!!! Note + This is not a user-facing documentation, but rather meant as a reference for *module authors* + + See: [clanService Authoring Guide](../../guides/authoring/clanServices/index.md) +""" + # Inventory options are already included under the buildClan attribute + # We just omitted them in the buildClan docs, because we want a separate output for the inventory model + with Path(CLAN_SERVICE_INTERFACE).open() as f: + options: dict[str, dict[str, Any]] = json.load(f) + + options_tree = options_to_tree(options, debug=True) + # Find the inventory options + + # Render the inventory options + # This for loop excludes the root node + # for option in options_tree.suboptions: + output += options_docs_from_tree(options_tree, init_level=2) + + outfile = Path(OUT) / "clanServices/clan-service-author-interface.md" + outfile.parent.mkdir(parents=True, exist_ok=True) + with Path.open(outfile, "w") as of: + of.write(output) + + @dataclass class Option: name: str @@ -959,6 +1001,8 @@ if __name__ == "__main__": # produce_build_clan_docs() produce_inventory_docs() + produce_clan_service_author_docs() + produce_clan_modules_docs() produce_clan_service_docs() diff --git a/docs/site/guides/authoring/clanServices/index.md b/docs/site/guides/authoring/clanServices/index.md index 4cf98b193..319e29e44 100644 --- a/docs/site/guides/authoring/clanServices/index.md +++ b/docs/site/guides/authoring/clanServices/index.md @@ -8,7 +8,9 @@ ## Service Module Specification This section explains how to author a clan service module. -We discussed the initial architecture in [01-clan-service-modules](../../../decisions/01-ClanModules.md) and decided to rework the format as follows: +We discussed the initial architecture in [01-clan-service-modules](../../../decisions/01-ClanModules.md) and decided to rework the format. + +For the full specification and current state see: **[Service Author Reference](../../../reference/clanServices/clan-service-author-interface.md)** ### A Minimal module @@ -49,6 +51,8 @@ The imported module file must fulfill at least the following requirements: } ``` +For more attributes see: **[Service Author Reference](../../../reference/clanServices/clan-service-author-interface.md)** + ### Adding functionality to the module While the very minimal module is valid in itself it has no way of adding any machines to it, because it doesn't specify any roles. @@ -254,3 +258,11 @@ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } ({self, lib, ...}: ``` The benefit of this approach is that downstream users can override the value of `myClan` by using `mkForce` or other priority modifiers. + +--- + +## Further + +- [Reference Documentation for Service Authors](../../../reference/clanServices/clan-service-author-interface.md) +- [Migration Guide from ClanModules to ClanServices](../../migrations/migrate-inventory-services.md) +- [Decision that lead to ClanServices](../../../decisions/01-ClanModules.md) diff --git a/lib/inventory/distributed-service/api-feature.nix b/lib/inventory/distributed-service/api-feature.nix index f745251f0..e5d4a8113 100644 --- a/lib/inventory/distributed-service/api-feature.nix +++ b/lib/inventory/distributed-service/api-feature.nix @@ -10,6 +10,7 @@ in { lib, config, ... }: { options.result.api = lib.mkOption { + visible = false; default = { }; type = lib.types.submodule ({ options.schema = lib.mkOption { diff --git a/lib/inventory/distributed-service/service-module.nix b/lib/inventory/distributed-service/service-module.nix index efdd5fe93..214414439 100644 --- a/lib/inventory/distributed-service/service-module.nix +++ b/lib/inventory/distributed-service/service-module.nix @@ -26,13 +26,6 @@ let ${builtins.toJSON (lib.attrNames config.roles)} ''; - # checkInstanceSettings = - # instanceName: instanceSettings: - # let - # unmatchedRoles = 1; - # in - # unmatchedRoles; - /** Merges the role- and machine-settings using the role interface @@ -154,11 +147,29 @@ let in { options = { + # TODO: deduplicate this with inventory.instances + # Although inventory has stricter constraints instances = mkOption { + # Instances are created in the inventory + visible = false; + defaultText = "Throws: 'The service must define its instances' when not defined"; default = throw '' The clan service module ${config.manifest.name} doesn't define any instances. - Did you forget to create instances via 'inventory.instances' ? + Did you forget to create instances via 'inventory.instances'? + ''; + description = '' + Instances of the service. + + An Instance is a user-specific deployment or configuration of a service. + It represents the active usage of the service configured to the user's settings or use case. + The `` of the instance is arbitrary, but must be unique. + + A common best practice is to name the instance after the 'service' and the 'use-case'. + + For example: + + - 'instances.zerotier-homelab = ...' for a zerotier instance that connects all machines of a homelab ''; type = attrsWith { @@ -174,6 +185,21 @@ in # apply = v: lib.seq (checkInstanceSettings name v) v; # }; options.roles = mkOption { + description = '' + Roles of the instance. + + A role is a specific behavior or configuration of the service. + It defines how the service should behave in the context of this instance. + The `` must match one of the roles defined in the service + + For example: + + - 'roles.client = ...' for a client role that connects to the service + - 'roles.server = ...' for a server role that provides the service + + Throws an error if empty, since this would mean that the service has no members. + ''; + defaultText = "Throws: 'The service must define members via roles' when not defined"; default = throw '' Instance '${name}' of service '${config.manifest.name}' mut define members via 'roles'. @@ -184,43 +210,51 @@ in placeholder = "roleName"; elemType = submoduleWith { modules = [ - ( - { ... }: - { - # instances.{instanceName}.roles.{roleName}.machines - options.machines = mkOption { - type = attrsWith { - placeholder = "machineName"; - elemType = submoduleWith { - modules = [ - (m: { - options.settings = mkOption { - type = types.raw; - description = "Settings of '${name}-machine': ${m.name}."; - default = { }; - }; - }) - ]; - }; + ({ + # instances.{instanceName}.roles.{roleName}.machines + options.machines = mkOption { + description = '' + Machines of the role. + + A machine is a physical or virtual machine that is part of the instance. + The `` must match the name of any machine defined in the clan. + + For example: + + - 'machines.my-machine = { ...; }' for a machine that is part of the instance + - 'machines.my-other-machine = { ...; }' for another machine that is part of the instance + ''; + type = attrsWith { + placeholder = "machineName"; + elemType = submoduleWith { + modules = [ + (m: { + options.settings = mkOption { + type = types.raw; + description = "Settings of '${name}-machine': ${m.name or ""}."; + default = { }; + }; + }) + ]; }; }; + }; - # instances.{instanceName}.roles.{roleName}.settings - # options._settings = mkOption { }; - # options._settingsViaTags = mkOption { }; - # A deferred module that combines _settingsViaTags with _settings - options.settings = mkOption { - type = types.raw; - description = "Settings of 'role': ${name}"; - default = { }; - }; + # instances.{instanceName}.roles.{roleName}.settings + # options._settings = mkOption { }; + # options._settingsViaTags = mkOption { }; + # A deferred module that combines _settingsViaTags with _settings + options.settings = mkOption { + type = types.raw; + description = "Settings of 'role': ${name}"; + default = { }; + }; - options.extraModules = lib.mkOption { - default = [ ]; - type = types.listOf (types.deferredModule); - }; - } - ) + options.extraModules = lib.mkOption { + default = [ ]; + type = types.listOf (types.deferredModule); + }; + }) ]; }; }; @@ -242,6 +276,22 @@ in }; }; roles = mkOption { + description = '' + Roles of the service. + + A role is a specific behavior or configuration of the service. + It defines how the service should behave in the context of the clan. + + The ``s of the service are defined here. Later usage of the roles must match one of the `roleNames`. + + For example: + + - 'roles.client = ...' for a client role that connects to the service + - 'roles.server = ...' for a server role that provides the service + + Throws an error if empty, since this would mean that the service has no way of adding members. + ''; + defaultText = "Throws: 'The service must define its roles' when not defined"; default = throw '' Role behavior of service '${config.manifest.name}' must be defined. A 'clan.service' module should always define its behavior via 'roles' @@ -263,32 +313,138 @@ in in { options.interface = mkOption { + description = '' + Abstract interface of the role. + + This is an abstract module which should define 'options' for the role's settings. + + Example: + + ```nix + { + options.timeout = mkOption { + type = types.int; + default = 30; + description = "Timeout in seconds"; + }; + } + ``` + + Note: + + - `machine.config` is not available here, since the role is definition is abstract. + - *defaults* that depend on the *machine* or *instance* should be added to *settings* later in 'perInstance' or 'perMachine' + ''; 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, ... } - ( - { ... }: + description = '' + Per-instance configuration of the role. + + This option is used to define instance-specific behavior for the service-role. (Example below) + + Although the type is a `deferredModule`, it helps to think of it as a function. + The 'function' takes the `instance-name` and some other `arguments`. + + *Arguments*: + + - `instanceName` (`string`): The name of the instance. + - `machine`: Machine information, containing: + ```nix { - options.nixosModule = mkOption { default = { }; }; - options.services = mkOption { - type = attrsWith { - placeholder = "serviceName"; - elemType = submoduleWith { - modules = [ ./service-module.nix ]; + name = "machineName"; + roles = ["client" "server" ... ]; + } + ``` + - `roles`: Attribute set of all roles of the instance, in the form: + ```nix + roles = { + client = { + machines = { + jon = { + settings = { + timeout = 60; }; }; - default = { }; + # ... }; - } - ) + settings = { + timeout = 30; + }; + }; + # ... + }; + ``` + + - `settings`: The settings of the role, as defined in `inventory` + ```nix + { + timeout = 30; + } + ``` + - `extendSettings`: A function that takes a module and returns a new module with extended settings. + ```nix + extendSettings { + timeout = mkForce 60; + }; + -> + { + timeout = 60; + } + ``` + + *Returns* an `attribute set` containing: + + - `nixosModule`: The NixOS module for the instance. + + ''; + type = types.deferredModuleWith { + staticModules = [ + ({ + options.nixosModule = mkOption { + type = types.deferredModule; + default = { }; + description = '' + This module is later imported to configure the machine with the config derived from service's settings. + + Example: + + ```nix + roles.client.perInstance = { instanceName, ... }: + { + # Keep in mind that this module is produced once per-instance + # Meaning you might end up with multiple of these modules. + # Make sure they can be imported all together without conflicts + # + # ↓ nixos-config + nixosModule = { config ,... }: { + # create one systemd service per instance + # It is a common practice to concatenate the *service-name* and *instance-name* + # To ensure globally unique systemd-units for the target machine + systemd.services."webly-''${instanceName}" = { + ... + }; + }; + } + ``` + ''; + }; + # TODO: Recursive services + options.services = mkOption { + visible = false; + type = attrsWith { + placeholder = "serviceName"; + elemType = submoduleWith { + modules = [ ./service-module.nix ]; + }; + }; + apply = _: throw "Not implemented yet"; + default = { }; + }; + }) ]; }; default = { }; @@ -333,26 +489,78 @@ in }; perMachine = mkOption { + description = '' + Per-machine configuration of the service. + + This option is used to define machine-specific settings for the service **once**, if any service-instance is used. + + Although the type is a `deferredModule`, it helps to think of it as a function. + The 'function' takes the `machine-name` and some other 'arguments' + + *Arguments*: + + - `machine`: `{ name :: string; roles :: listOf String }` + - `instances`: The scope of the machine, containing all instances and roles that the machine is part of. + ```nix + { + instances = { + = { + roles = { + = { + # Per-machine settings + machines = { = { settings = { ... }; }; }; }; + # Per-role settings + settings = { ... }; + }; + }; + }; + } + ``` + + *Returns* an `attribute set` containing: + + - `nixosModule`: The NixOS module for the machine. + + ''; 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 ]; - }; + ({ + options.nixosModule = mkOption { + type = types.deferredModule; + default = { }; + description = '' + A single NixOS module for the machine. + + This module is later imported to configure the machine with the config derived from service's settings. + + Example: + + ```nix + # ↓ machine.roles ... + perMachine = { machine, ... }: + { # ↓ nixos-config + nixosModule = { config ,... }: { + systemd.services.foo = { + enable = true; + }; + } + } + ``` + ''; + }; + # TODO: Recursive services + options.services = mkOption { + visible = false; + type = attrsWith { + placeholder = "serviceName"; + elemType = submoduleWith { + modules = [ ./service-module.nix ]; }; - default = { }; }; - } - ) + apply = _: throw "Not implemented yet"; + default = { }; + }; + }) ]; }; default = { }; @@ -428,6 +636,7 @@ in } */ result.allRoles = mkOption { + visible = false; readOnly = true; default = lib.mapAttrs (roleName: roleCfg: { allInstances = lib.mapAttrs (instanceName: instanceCfg: { @@ -436,15 +645,14 @@ in let instanceRes = roleCfg.perInstance instanceName machineName; in - { + instanceRes + // { nixosModule = { imports = [ # Result of the applied 'perInstance = {...}: { nixosModule = { ... }; }' instanceRes.nixosModule ] ++ instanceCfg.roles.${roleName}.extraModules; }; - # TODO: nested services - services = { }; } ) instanceCfg.roles.${roleName}.machines or { }; @@ -454,10 +662,12 @@ in result.assertions = mkOption { default = { }; + visible = false; type = types.attrsOf types.raw; }; result.allMachines = mkOption { + visible = false; readOnly = true; default = let @@ -505,6 +715,7 @@ in }; result.final = mkOption { + visible = false; readOnly = true; default = lib.mapAttrs ( machineName: machineResult: diff --git a/lib/inventory/distributed-service/tests/per_instance_args.nix b/lib/inventory/distributed-service/tests/per_instance_args.nix index d27fe473f..82a66dd75 100644 --- a/lib/inventory/distributed-service/tests/per_instance_args.nix +++ b/lib/inventory/distributed-service/tests/per_instance_args.nix @@ -37,17 +37,19 @@ let }; in { - nixosModule = { - inherit - instanceName - settings - machine - roles - ; + options.passthru = lib.mkOption { + default = { + inherit + instanceName + settings + machine + roles + ; - # We are double vendoring the settings - # To test that we can do it indefinitely - vendoredSettings = finalSettings; + # We are double vendoring the settings + # To test that we can do it indefinitely + vendoredSettings = finalSettings; + }; }; }; }; @@ -96,30 +98,26 @@ let . "foo-peer"; }; }; }; settings = { timeout = "foo-peer-jon"; }; vendoredSettings = { timeout = "conf . . ig.thing"; }; } ]; } . */ - unwrapModule = m: (builtins.head m.imports); in { # settings should evaluate test_per_instance_arguments = { - expr = - let - m = ( - unwrapModule - res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.nixosModule - ); - in - { - instanceName = m.instanceName; + expr = { + instanceName = + res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.passthru.instanceName; - # settings are specific. - # Below we access: - # instance = instance_foo - # roles = peer - # machines = jon - settings = m.settings; - machine = m.machine; - roles = m.roles; - }; + # settings are specific. + # Below we access: + # instance = instance_foo + # roles = peer + # machines = jon + settings = + res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.settings; + machine = + res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.machine; + roles = + res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.roles; + }; expected = { instanceName = "instance_foo"; settings = { @@ -161,10 +159,9 @@ in # TODO: Cannot be tested like this anymore test_per_instance_settings_vendoring = { + x = res.importedModulesEvaluated.self-A.config; expr = - (unwrapModule - res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances."instance_foo".allMachines.jon.nixosModule - ).vendoredSettings; + res.importedModulesEvaluated.self-A.config.result.allRoles.peer.allInstances.instance_foo.allMachines.jon.passthru.vendoredSettings; expected = { timeout = "config.thing"; }; diff --git a/lib/inventory/distributed-service/tests/per_machine_args.nix b/lib/inventory/distributed-service/tests/per_machine_args.nix index bca087a4f..34fe6c38d 100644 --- a/lib/inventory/distributed-service/tests/per_machine_args.nix +++ b/lib/inventory/distributed-service/tests/per_machine_args.nix @@ -28,8 +28,10 @@ let perMachine = { instances, machine, ... }: { - nixosModule = { - inherit instances machine; + options.passthru = lib.mkOption { + default = { + inherit instances machine; + }; }; }; }; @@ -76,7 +78,7 @@ in inherit res; expr = { hasMachineSettings = - res.importedModulesEvaluated.self-A.config.result.allMachines.jon.nixosModule.instances.instance_foo.roles.peer.machines.jon + res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon ? settings; # settings are specific. @@ -84,10 +86,10 @@ in # instance = instance_foo # roles = peer # machines = jon - specificMachineSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.nixosModule.instances.instance_foo.roles.peer.machines.jon.settings; + specificMachineSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.machines.jon.settings; hasRoleSettings = - res.importedModulesEvaluated.self-A.config.result.allMachines.jon.nixosModule.instances.instance_foo.roles.peer + res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer ? settings; # settings are specific. @@ -95,7 +97,7 @@ in # instance = instance_foo # roles = peer # machines = * - specificRoleSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.nixosModule.instances.instance_foo.roles.peer.settings; + specificRoleSettings = filterInternals res.importedModulesEvaluated.self-A.config.result.allMachines.jon.passthru.instances.instance_foo.roles.peer.settings; }; expected = { hasMachineSettings = true; diff --git a/lib/inventory/flake-module.nix b/lib/inventory/flake-module.nix index 4c36fb9ee..34b76890a 100644 --- a/lib/inventory/flake-module.nix +++ b/lib/inventory/flake-module.nix @@ -43,6 +43,16 @@ in } ); + legacyPackages.clan-service-module-interface = + (pkgs.nixosOptionsDoc { + options = + (self.clanLib.inventory.evalClanService { + modules = [ ]; + prefix = [ ]; + }).options; + warningsAreErrors = true; + }).optionsJSON; + # Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests legacyPackages.evalTests-inventory = import ./tests { inherit lib; diff --git a/lib/types/flake-module.nix b/lib/types/flake-module.nix index 2b75953c5..e0e0b58a6 100644 --- a/lib/types/flake-module.nix +++ b/lib/types/flake-module.nix @@ -20,6 +20,5 @@ in { imports = [ test-types-module ]; - legacyPackages.xxx = { }; }; }