diff --git a/clanModules/borgbackup-static/default.nix b/clanModules/borgbackup-static/default.nix index 36fa6d6f..931ea15f 100644 --- a/clanModules/borgbackup-static/default.nix +++ b/clanModules/borgbackup-static/default.nix @@ -3,7 +3,7 @@ let clanDir = config.clan.core.clanDir; machineDir = clanDir + "/machines/"; in -{ +lib.warn "This module is deprecated use the service via the inventory interface instead." { imports = [ ../borgbackup ]; options.clan.borgbackup-static = { diff --git a/clanModules/borgbackup/default.nix b/clanModules/borgbackup/default.nix index a625b83f..b46624be 100644 --- a/clanModules/borgbackup/default.nix +++ b/clanModules/borgbackup/default.nix @@ -27,8 +27,55 @@ let exit 1 fi ''; + + # Each .nix file in the roles directory is a role + # TODO: Helper function to set available roles within module meta. + roles = + if builtins.pathExists ./roles then + lib.pipe ./roles [ + builtins.readDir + (lib.filterAttrs (_n: v: v == "regular")) + lib.attrNames + (map (fileName: lib.removeSuffix ".nix" fileName)) + ] + else + null; + + # TODO: make this an interface of every module + # Maybe load from readme.md + metaInfoOption = lib.mkOption { + readOnly = true; + description = '' + Meta is used to retrieve information about this module. + + - `availableRoles` is a list of roles that can be assigned via the inventory. + - `category` is used to group services in the clan marketplace. + - `description` is a short description of the service for the clan marketplace. + ''; + default = { + description = "Borgbackup is a backup program. Optionally, it supports compression and authenticated encryption."; + availableRoles = roles; + category = "backup"; + }; + type = lib.types.submodule { + options = { + description = lib.mkOption { type = lib.types.str; }; + availableRoles = lib.mkOption { type = lib.types.nullOr (lib.types.listOf lib.types.str); }; + category = lib.mkOption { + description = "A category for the service. This is used to group services in the clan ui"; + type = lib.types.enum [ + "backup" + "network" + ]; + }; + }; + }; + }; in { + + options.clan.borgbackup.meta = metaInfoOption; + options.clan.borgbackup.destinations = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule ( @@ -76,7 +123,7 @@ in lib.nameValuePair "borgbackup-job-${dest.name}" { # since borgbackup mounts the system read-only, we need to run in a ExecStartPre script, so we can generate additional files. serviceConfig.ExecStartPre = [ - (''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}'') + ''+${pkgs.writeShellScript "borgbackup-job-${dest.name}-pre-backup-commands" preBackupScript}'' ]; } ) cfg.destinations; diff --git a/clanModules/borgbackup/roles/client.nix b/clanModules/borgbackup/roles/client.nix new file mode 100644 index 00000000..84ce41ef --- /dev/null +++ b/clanModules/borgbackup/roles/client.nix @@ -0,0 +1,30 @@ +{ config, lib, ... }: +let + instances = config.clan.inventory.services.borgbackup; + # roles = { ${role_name} :: { machines :: [string] } } + allServers = lib.foldlAttrs ( + acc: _instanceName: instanceConfig: + acc + ++ ( + if builtins.elem machineName instanceConfig.roles.client.machines then + instanceConfig.roles.server.machines + else + [ ] + ) + ) [ ] instances; + + inherit (config.clan.core) machineName; +in +{ + config.clan.borgbackup.destinations = + let + + destinations = builtins.map (serverName: { + name = serverName; + value = { + repo = "borg@${serverName}:/var/lib/borgbackup/${machineName}"; + }; + }) allServers; + in + (builtins.listToAttrs destinations); +} diff --git a/clanModules/borgbackup/roles/server.nix b/clanModules/borgbackup/roles/server.nix new file mode 100644 index 00000000..48c7089c --- /dev/null +++ b/clanModules/borgbackup/roles/server.nix @@ -0,0 +1,45 @@ +{ config, lib, ... }: +let + clanDir = config.clan.core.clanDir; + machineDir = clanDir + "/machines/"; + inherit (config.clan.core) machineName; + + instances = config.clan.inventory.services.borgbackup; + + # roles = { ${role_name} :: { machines :: [string] } } + + allClients = lib.foldlAttrs ( + acc: _instanceName: instanceConfig: + acc + ++ ( + if (builtins.elem machineName instanceConfig.roles.server.machines) then + instanceConfig.roles.client.machines + else + [ ] + ) + ) [ ] instances; +in +{ + config.services.borgbackup.repos = + let + borgbackupIpMachinePath = machines: machineDir + machines + "/facts/borgbackup.ssh.pub"; + machinesMaybeKey = builtins.map ( + machine: + let + fullPath = borgbackupIpMachinePath machine; + in + if builtins.pathExists fullPath then machine else null + ) allClients; + + machinesWithKey = lib.filter (x: x != null) machinesMaybeKey; + + hosts = builtins.map (machine: { + name = machine; + value = { + path = "/var/lib/borgbackup/${machine}"; + authorizedKeys = [ (builtins.readFile (borgbackupIpMachinePath machine)) ]; + }; + }) machinesWithKey; + in + if (builtins.listToAttrs hosts) != [ ] then builtins.listToAttrs hosts else { }; +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d43ee81b..daaf98e9 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -49,7 +49,7 @@ nav: - Mesh VPN: getting-started/mesh-vpn.md - Backup & Restore: getting-started/backups.md - Flake-parts: getting-started/flake-parts.md - - Modules: + - Reference: - Clan Modules: - reference/clanModules/borgbackup-static.md - reference/clanModules/borgbackup.md diff --git a/docs/nix/flake-module.nix b/docs/nix/flake-module.nix index b398db44..8370c0a2 100644 --- a/docs/nix/flake-module.nix +++ b/docs/nix/flake-module.nix @@ -19,6 +19,7 @@ clanModulesFileInfo = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModules); clanModulesReadmes = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesReadmes); + clanModulesMeta = pkgs.writeText "info.json" (builtins.toJSON jsonDocs.clanModulesMeta); # Simply evaluated options (JSON) renderOptions = @@ -54,6 +55,7 @@ # A file that contains the links to all clanModule docs export CLAN_MODULES=${clanModulesFileInfo} export CLAN_MODULES_READMES=${clanModulesReadmes} + export CLAN_MODULES_META=${clanModulesMeta} mkdir $out diff --git a/docs/nix/get-module-docs.nix b/docs/nix/get-module-docs.nix index a05fe847..b1d2ed9d 100644 --- a/docs/nix/get-module-docs.nix +++ b/docs/nix/get-module-docs.nix @@ -43,11 +43,16 @@ let module_name: _module: self.lib.modules.getReadme module_name ) clanModules; + clanModulesMeta = builtins.mapAttrs ( + module_name: _module: + (self.lib.evalClanModules [ module_name ]).config.clan.${module_name}.meta or { } + ) clanModules; + # clanCore docs clanCoreDocs = (evalDocs (getOptions [ ]).clan.core).optionsJSON; in { - inherit clanModulesReadmes; + inherit clanModulesReadmes clanModulesMeta; clanCore = clanCoreDocs; clanModules = clanModulesDocs; } diff --git a/docs/nix/scripts/renderOptions.py b/docs/nix/scripts/renderOptions.py index 4480d410..909d6108 100644 --- a/docs/nix/scripts/renderOptions.py +++ b/docs/nix/scripts/renderOptions.py @@ -32,6 +32,8 @@ from typing import Any CLAN_CORE = os.getenv("CLAN_CORE") CLAN_MODULES = os.environ.get("CLAN_MODULES") CLAN_MODULES_READMES = os.environ.get("CLAN_MODULES_READMES") +CLAN_MODULES_META = os.environ.get("CLAN_MODULES_META") + OUT = os.environ.get("out") @@ -76,7 +78,9 @@ def render_option(name: str, option: dict[str, Any], level: int = 3) -> str: res = f""" {"#" * level} {sanitize(name)} -{"Readonly" if read_only else ""} + +{"**Readonly**" if read_only else ""} + {option.get("description", "No description available.")} **Type**: `{option["type"]}` @@ -188,6 +192,35 @@ def produce_clan_core_docs() -> None: of.write(output) +def render_meta(meta: dict[str, Any], module_name: str) -> str: + roles = meta.get("availableRoles", None) + + if roles: + roles_list = "\n".join([f" - `{r}`" for r in roles]) + return f""" +???+ tip "Inventory (WIP)" + + Predefined roles: + +{roles_list} + + Usage: + + ```{{.nix hl_lines="4"}} + buildClan {{ + inventory.services = {{ + {module_name}.instance_1 = {{ + roles.{roles[0]}.machines = [ "sara_machine" ]; + # ... + }}; + }}; + }} + ``` + +""" + return "" + + def produce_clan_modules_docs() -> None: if not CLAN_MODULES: raise ValueError( @@ -198,6 +231,11 @@ def produce_clan_modules_docs() -> None: f"Environment variables are not set correctly: $CLAN_MODULES_READMES={CLAN_MODULES_READMES}" ) + if not CLAN_MODULES_META: + raise ValueError( + f"Environment variables are not set correctly: $CLAN_MODULES_META={CLAN_MODULES_META}" + ) + if not OUT: raise ValueError(f"Environment variables are not set correctly: $out={OUT}") @@ -207,6 +245,10 @@ def produce_clan_modules_docs() -> None: with open(CLAN_MODULES_READMES) as readme: readme_map: dict[str, str] = json.load(readme) + with open(CLAN_MODULES_META) as f: + meta_map: dict[str, Any] = json.load(f) + print(meta_map) + # {'borgbackup': '/nix/store/hi17dwgy7963ddd4ijh81fv0c9sbh8sw-options.json', ... } for module_name, options_file in links.items(): with open(Path(options_file) / "share/doc/nixos/options.json") as f: @@ -217,6 +259,11 @@ def produce_clan_modules_docs() -> None: if readme_map.get(module_name, None): output += f"{readme_map[module_name]}\n" + # Add meta information: + # - Inventory implementation status + if meta_map.get(module_name, None): + output += render_meta(meta_map.get(module_name, {}), module_name) + output += module_usage(module_name) output += options_head if len(options.items()) else "" diff --git a/flake.nix b/flake.nix index 341c5b84..be6a1db8 100644 --- a/flake.nix +++ b/flake.nix @@ -53,8 +53,6 @@ ./nixosModules/flake-module.nix ./pkgs/flake-module.nix ./templates/flake-module.nix - - ./inventory/flake-module.nix ]; } ); diff --git a/flakeModules/clan.nix b/flakeModules/clan.nix index 139ef8d9..b1a1247a 100644 --- a/flakeModules/clan.nix +++ b/flakeModules/clan.nix @@ -13,7 +13,6 @@ let inherit lib clan-core; inherit (inputs) nixpkgs; }; - cfg = config.clan; in { @@ -91,6 +90,9 @@ in clanInternals = lib.mkOption { type = lib.types.submodule { options = { + inventory = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; }; + inventoryFile = lib.mkOption { type = lib.types.unspecified; }; + meta = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; }; all-machines-json = lib.mkOption { type = lib.types.attrsOf lib.types.unspecified; }; machines = lib.mkOption { type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified); }; diff --git a/inventory/README.md b/inventory/README.md deleted file mode 100644 index 318d1bf0..00000000 --- a/inventory/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Inventory - -This part provides a specification for the inventory. - -It is used for design phase and as validation helper. - -> Cue is less verbose and easier to understand and maintain than json-schema. -> Json-schema, if needed can be easily generated on-the fly. - -## Checking validity - -Directly check a json against the schema - -`cue vet inventory.json root.cue -d '#Root'` - -## Json schema - -Export the json-schema i.e. for usage in python / javascript / nix - -`cue export --out openapi root.cue` - -## Usage - -Comments are rendered as descriptions in the json schema. - -```cue -// A name of the clan (primarily shown by the UI) -name: string -``` - -Cue open sets. In the following `foo = {...}` means that the key `foo` can contain any arbitrary json object. - -```cue -foo: { ... } -``` - -Cue dynamic keys. - -```cue -[string]: { - attr: string -} -``` - -This is the schema of - -```json -{ - "a": { - "attr": "foo" - }, - "b": { - "attr": "bar" - } - // ... Indefinitely more dynamic keys of type "string" -} -``` diff --git a/inventory/example_flake.nix b/inventory/example_flake.nix deleted file mode 100644 index 11be13a7..00000000 --- a/inventory/example_flake.nix +++ /dev/null @@ -1,137 +0,0 @@ -{ - description = ""; - - inputs.clan-core.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz"; - - outputs = - { clan-core, ... }: - let - pkgs = clan-core.inputs.nixpkgs.legacyPackages.${system}; - system = "x86_64-linux"; - in - # Usage see: https://docs.clan.lol - # nice_flake_interface -> buildInventory() -> Inventory -> buildClanFromInventory() -> nixosConfigurations - # buildClanFromInventory = inventory: evalModules { - # extraAttrs = { inherit inventory; }; - # # (attrNames inventory.machines) - # }; - # clan = - # clan-core.lib.buildClanFromInventory [ - # # Inventory 0 (loads the json file managed by the Python API) - # (builtins.fromJSON (builtins.readFile ./inventory.json)) - # # -> - # # { - # # services."backups_1".autoIncludeMachines = true; - # # services."backups_1".module = "borgbackup"; - # # ... etc. - # # } - # ] - # ++ (buildInventory { - # clanName = "nice_flake_interface"; - # description = "A nice flake interface"; - # icon = "assets/icon.png"; - # machines = { - # jon = { - # # Just regular nixos/clan configuration ? - # # config = { - # # imports = [ - # # ./modules/shared.nix - # # ./machines/jon/configuration.nix - # # ]; - # # nixpkgs.hostPlatform = system; - # # # Set this for clan commands use ssh i.e. `clan machines update` - # # # If you change the hostname, you need to update this line to root@ - # # # This only works however if you have avahi running on your admin machine else use IP - # # clan.networking.targetHost = pkgs.lib.mkDefault "root@jon"; - # # # ssh root@flash-installer.local lsblk --output NAME,ID-LINK,FSTYPE,SIZE,MOUNTPOINT - # # disko.devices.disk.main = { - # # device = "/dev/disk/by-id/__CHANGE_ME__"; - # # }; - # # # IMPORTANT! Add your SSH key here - # # # e.g. > cat ~/.ssh/id_ed25519.pub - # # users.users.root.openssh.authorizedKeys.keys = throw '' - # # Don't forget to add your SSH key here! - # # users.users.root.openssh.authorizedKeys.keys = [ "" ] - # # ''; - # # # Zerotier needs one controller to accept new nodes. Once accepted - # # # the controller can be offline and routing still works. - # # clan.networking.zerotier.controller.enable = true; - # # }; - # }; - # }; - # }) - # ++ [ - # # Low level inventory overrides (comes at the end) - # { - # services."backups_2".autoIncludeMachines = true; - # services."backups_2".module = "borgbackup"; - # } - # ]; - # # buildClan :: [ Partial ] -> Inventory - # # foldl' (acc: v: lib.recursiveUpdate acc v) {} [] - # inventory = [ - # # import json - # {...} - # # power user flake - # {...} - # ] - # # With Module system - # # Pros: Easy to understand, - # # Cons: Verbose, hard to maintain - # # buildClan :: { modules = [ { config = Partial; options :: InventoryOptions; } } ]; } -> Inventory - # eval = lib.evalModules { - # modules = [ - # { - # # Inventory Schema - # # Python validation - # options = {...} - # } - # { - # config = map lib.mkDefault - # (builtins.fromJSON (builtins.readFile ./inventory.json)) - # } - # { - # # User provided - # config = {...} - # } - # # Later overrides. - # { - # lib.mkForce ... - # } - # ]; - # } - # nixosConfigurations = lib.evalModules inventory; - # eval.config.inventory - # # - # eval.config.machines.jon#nixosConfig - # eval.config.machines.sara#nixosConfig - # - # {inventory, config, ...}:{ - # hostname = config.machines.sara # Invalid - # hostname = inventory.machines.sara.hostname # Valid - # } - /* - # Type - - buildInventory :: { - clanName :: string - machines :: { - ${name} :: { - config :: { - # NixOS configuration - }; - }; - }; - # ... More mapped inventory options - # i.e. shared config for all machines - } -> Inventory - */ - { - # all machines managed by Clan - inherit (clan) nixosConfigurations clanInternals; - # add the Clan cli tool to the dev shell - devShells.${system}.default = pkgs.mkShell { - packages = [ clan-core.packages.${system}.clan-cli ]; - }; - }; -} diff --git a/inventory/src/machines/machines.cue b/inventory/src/machines/machines.cue deleted file mode 100644 index a917e1e9..00000000 --- a/inventory/src/machines/machines.cue +++ /dev/null @@ -1,8 +0,0 @@ -package machines - - -#machine: machines: [string]: { - name: string, - description?: string, - icon?: string -} \ No newline at end of file diff --git a/inventory/src/tests/borgbackup.json b/inventory/src/tests/borgbackup.json deleted file mode 100644 index 43e04322..00000000 --- a/inventory/src/tests/borgbackup.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "machines": { - "camina_machine": { - "name": "camina" - }, - "vyr_machine": { - "name": "vyr" - }, - "vi_machine": { - "name": "vi" - } - }, - "meta": { - "name": "kenjis clan" - }, - "services": { - "backup": { - "meta": { - "name": "My backup" - }, - "module": "borbackup-static", - "machineConfig": { - "vyr": { - "roles": ["server"] - }, - "vi": { - "roles": ["client"] - }, - "camina_machine": { - "roles": ["client"] - } - }, - "config": { - "folders": ["/home", "/root", "/var", "/etc"] - } - } - } -} diff --git a/inventory/src/tests/syncthing.json b/inventory/src/tests/syncthing.json deleted file mode 100644 index 1f5d59cf..00000000 --- a/inventory/src/tests/syncthing.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "machines": { - "camina_machine": { - "name": "camina" - }, - "vyr": { - "name": "vyr" - }, - "vi": { - "name": "vi" - } - }, - "meta": { - "name": "kenjis clan" - }, - "services": { - "sync_files": { - "meta": { - "name": "My sync" - }, - "module": "syncthing-static-peers", - "machineConfig": { - "vyr": {}, - "vi": {}, - "camina_machine": {} - }, - "config": { - "folders": { - "test": { - "path": "~/data/docs", - "devices": ["camina", "vyr", "vi"] - }, - "videos": { - "path": "~/data/videos", - "devices": ["camina", "vyr", "ezra"] - }, - "playlist": { - "path": "~/data/playlist", - "devices": ["camina", "vyr", "ezra"] - } - } - } - } - } -} diff --git a/inventory/src/tests/zerotier.json b/inventory/src/tests/zerotier.json deleted file mode 100644 index 21a2b097..00000000 --- a/inventory/src/tests/zerotier.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "machines": { - "camina_machine": { - "name": "camina" - }, - "vyr_machine": { - "name": "vyr" - }, - "vi_machine": { - "name": "vi" - } - }, - "meta": { - "name": "kenjis clan" - }, - "services": { - "backup": { - "meta": { - "name": "My backup" - }, - "module": "borbackup-static", - "machineConfig": { - "vyr_machine": { - "roles": ["server"] - }, - "vi_machine": { - "roles": ["peer"] - }, - "camina_machine": { - "roles": ["peer"] - } - }, - "config": {} - } - } -} diff --git a/lib/build-clan/default.nix b/lib/build-clan/default.nix index 90bb8584..452f3839 100644 --- a/lib/build-clan/default.nix +++ b/lib/build-clan/default.nix @@ -12,53 +12,83 @@ # DEPRECATED: use meta.icon instead clanIcon ? null, # A path to an icon to be used for the clan, should be the same for all machines meta ? { }, # A set containing clan meta: name :: string, icon :: string, description :: string - pkgsForSystem ? (_system: null), # A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system. -# This improves performance, but all nipxkgs.* options will be ignored. + # A map from arch to pkgs, if specified this nixpkgs will be only imported once for each system. + # This improves performance, but all nipxkgs.* options will be ignored. + pkgsForSystem ? (_system: null), + /* + Low level inventory configuration. + Overrides the services configuration. + */ + inventory ? { }, }: let - deprecationWarnings = [ - (lib.warnIf ( - clanName != null - ) "clanName is deprecated, please use meta.name instead. ${clanName}" null) - (lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null) - ]; + # Internal inventory, this is the result of merging all potential inventory sources: + # - Default instances configured via 'services' + # - The inventory overrides + # - Machines that exist in inventory.machines + # - Machines explicitly configured via 'machines' argument + # - Machines that exist in the machines directory + # Checks on the module level: + # - Each service role must reference a valid machine after all machines are merged + mergedInventory = + (lib.evalModules { + modules = [ + clan-core.lib.inventory.interface + { inherit meta; } + ( + if + builtins.pathExists "${directory}/inventory.json" + # Is recursively applied. Any explicit nix will override. + then + lib.mkDefault (builtins.fromJSON (builtins.readFile "${directory}/inventory.json")) + else + { } + ) + inventory + # Machines explicitly configured via 'machines' argument + { + # { ${name} :: meta // { name, tags } } + machines = lib.mapAttrs ( + name: config: + (lib.attrByPath [ + "clan" + "meta" + ] { } config) + // { + # meta.name default is the attribute name of the machine + name = lib.mkDefault ( + lib.attrByPath [ + "clan" + "meta" + "name" + ] name config + ); + tags = lib.attrByPath [ + "clan" + "tags" + ] [ ] config; + } + ) machines; + } + # Will be deprecated + { machines = lib.mapAttrs (_n: _: lib.mkDefault { }) machinesDirs; } + + # Deprecated interface + (if clanName != null then { meta.name = clanName; } else { }) + (if clanIcon != null then { meta.icon = clanIcon; } else { }) + ]; + }).config; + + inherit (clan-core.lib.inventory) buildInventory; + + # map from machine name to service configuration + # { ${machineName} :: Config } + serviceConfigs = buildInventory mergedInventory; machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") ( builtins.readDir (directory + /machines) ); - mergedMeta = - let - metaFromFile = - if (builtins.pathExists "${directory}/clan/meta.json") then - let - settings = builtins.fromJSON (builtins.readFile "${directory}/clan/meta.json"); - in - settings - else - { }; - legacyMeta = lib.filterAttrs (_: v: v != null) { - name = clanName; - icon = clanIcon; - }; - optionsMeta = lib.filterAttrs (_: v: v != null) meta; - - warnings = - builtins.map ( - name: - if - metaFromFile.${name} or null != optionsMeta.${name} or null && optionsMeta.${name} or null != null - then - lib.warn "meta.${name} is set in different places. (exlicit option meta.${name} overrides ${directory}/clan/meta.json)" null - else - null - ) (builtins.attrNames metaFromFile) - ++ [ (if (res.name or null == null) then (throw "meta.name should be set") else null) ]; - res = metaFromFile // legacyMeta // optionsMeta; - in - # Print out warnings before returning the merged result - builtins.deepSeq warnings res; - machineSettings = machineName: # CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily @@ -71,14 +101,16 @@ let builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)) ); - # Read additional imports specified via a config option in settings.json - # This is not an infinite recursion, because the imports are discovered here - # before calling evalModules. - # It is still useful to have the imports as an option, as this allows for type - # checking and easy integration with the config frontend(s) machineImports = machineSettings: map (module: clan-core.clanModules.${module}) (machineSettings.clanImports or [ ]); + deprecationWarnings = [ + (lib.warnIf ( + clanName != null + ) "clanName is deprecated, please use meta.name instead. ${clanName}" null) + (lib.warnIf (clanIcon != null) "clanIcon is deprecated, please use meta.icon instead" null) + ]; + # TODO: remove default system once we have a hardware-config mechanism nixosConfiguration = { @@ -98,6 +130,9 @@ let clan-core.nixosModules.clanCore extraConfig (machines.${name} or { }) + # Inherit the inventory assertions ? + { inherit (mergedInventory) assertions; } + { imports = serviceConfigs.${name} or { }; } ( { # Settings @@ -125,7 +160,7 @@ let } // specialArgs; }; - allMachines = machinesDirs // machines; + allMachines = mergedInventory.machines or { }; supportedSystems = [ "x86_64-linux" @@ -177,9 +212,10 @@ builtins.deepSeq deprecationWarnings { inherit nixosConfigurations; clanInternals = { - # Evaluated clan meta - # Merged /clan/meta.json with overrides from buildClan - meta = mergedMeta; + meta = mergedInventory.meta; + inventory = mergedInventory; + + inventoryFile = "${directory}/inventory.json"; # machine specifics machines = configsPerSystem; diff --git a/lib/default.nix b/lib/default.nix index ef5fe7ac..086fcce8 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -6,6 +6,7 @@ }: { evalClanModules = import ./eval-clan-modules { inherit clan-core nixpkgs lib; }; + inventory = import ./inventory { inherit lib clan-core; }; jsonschema = import ./jsonschema { inherit lib; }; modules = import ./description.nix { inherit clan-core lib; }; buildClan = import ./build-clan { inherit clan-core lib nixpkgs; }; diff --git a/lib/flake-module.nix b/lib/flake-module.nix index ef93467b..0785872a 100644 --- a/lib/flake-module.nix +++ b/lib/flake-module.nix @@ -5,7 +5,10 @@ ... }: { - imports = [ ./jsonschema/flake-module.nix ]; + imports = [ + ./jsonschema/flake-module.nix + ./inventory/flake-module.nix + ]; flake.lib = import ./default.nix { inherit lib inputs; inherit (inputs) nixpkgs; diff --git a/inventory/.envrc b/lib/inventory/.envrc similarity index 100% rename from inventory/.envrc rename to lib/inventory/.envrc diff --git a/lib/inventory/README.md b/lib/inventory/README.md new file mode 100644 index 00000000..4776a388 --- /dev/null +++ b/lib/inventory/README.md @@ -0,0 +1,90 @@ +# Inventory + +The inventory is our concept for distributed services. Users can configure multiple machines with minimal effort. + +- The inventory acts as a declarative source of truth for all machine configurations. +- Users can easily add or remove machines to/from services. +- Ensures that all machines and services are configured consistently, across multiple nixosConfigs. +- Defaults and predefined roles in our modules minimizes the need for manual configuration. + +Open questions: + +- [ ] How do we set default role, description and other metadata? + - It must be accessible from Python. + - It must set the value in the module system. + +- [ ] Inventory might use assertions. Should each machine inherit the inventory assertions ? + +- [ ] Is the service config interface the same as the module config interface ? + +- [ ] As a user do I want to see borgbackup as the high level category? + + +Architecture + +``` +nixosConfig < machine_module < inventory +--------------------------------------------- +nixos < borgbackup <- inventory <-> UI + + creates the config Maps from high level services to the borgbackup clan module + for ONE machine Inventory is completely serializable. + UI can interact with the inventory to define machines, and services + Defining Users is out of scope for the first prototype. +``` + +## Provides a specification for the inventory + +It is used for design phase and as validation helper. + +> Cue is less verbose and easier to understand and maintain than json-schema. +> Json-schema, if needed can be easily generated on-the fly. + +## Checking validity + +Directly check a json against the schema + +`cue vet inventory.json root.cue -d '#Root'` + +## Json schema + +Export the json-schema i.e. for usage in python / javascript / nix + +`cue export --out openapi root.cue` + +## Usage + +Comments are rendered as descriptions in the json schema. + +```cue +// A name of the clan (primarily shown by the UI) +name: string +``` + +Cue open sets. In the following `foo = {...}` means that the key `foo` can contain any arbitrary json object. + +```cue +foo: { ... } +``` + +Cue dynamic keys. + +```cue +[string]: { + attr: string +} +``` + +This is the schema of + +```json +{ + "a": { + "attr": "foo" + }, + "b": { + "attr": "bar" + } + // ... Indefinitely more dynamic keys of type "string" +} +``` diff --git a/lib/inventory/build-inventory/default.nix b/lib/inventory/build-inventory/default.nix new file mode 100644 index 00000000..a5ab65bc --- /dev/null +++ b/lib/inventory/build-inventory/default.nix @@ -0,0 +1,106 @@ +# Generate partial NixOS configurations for every machine in the inventory +# This function is responsible for generating the module configuration for every machine in the inventory. +{ lib, clan-core }: +inventory: +let + machines = machinesFromInventory inventory; + + resolveTags = + # Inventory, { machines :: [string], tags :: [string] } + inventory: members: { + machines = + members.machines or [ ] + ++ (builtins.foldl' ( + acc: tag: + let + tagMembers = builtins.attrNames ( + lib.filterAttrs (_n: v: builtins.elem tag v.tags or [ ]) inventory.machines + ); + in + # throw "Machine tag ${tag} not found. Not machine with: tag ${tagName} not in inventory."; + if tagMembers == [ ] then + throw "Machine tag ${tag} not found. Not machine with: tag ${tag} not in inventory." + else + acc ++ tagMembers + ) [ ] members.tags or [ ]); + }; + + /* + Returns a NixOS configuration for every machine in the inventory. + + machinesFromInventory :: Inventory -> { ${machine_name} :: NixOSConfiguration } + */ + machinesFromInventory = + inventory: + # For every machine in the inventory, build a NixOS configuration + # For each machine generate config, forEach service, if the machine is used. + builtins.mapAttrs ( + machineName: _: + lib.foldlAttrs ( + # [ Modules ], String, { ${instance_name} :: ServiceConfig } + acc: moduleName: serviceConfigs: + acc + # Collect service config + ++ (lib.foldlAttrs ( + # [ Modules ], String, ServiceConfig + acc2: instanceName: serviceConfig: + let + resolvedRoles = builtins.mapAttrs ( + _roleName: members: resolveTags inventory members + ) serviceConfig.roles; + + isInService = builtins.any (members: builtins.elem machineName members.machines) ( + builtins.attrValues resolvedRoles + ); + + # Inverse map of roles. Allows for easy lookup of roles for a given machine. + # { ${machine_name} :: [roles] + inverseRoles = lib.foldlAttrs ( + acc: roleName: + { machines }: + acc + // builtins.foldl' ( + acc2: machineName: acc2 // { ${machineName} = (acc.${machineName} or [ ]) ++ [ roleName ]; } + ) { } machines + ) { } resolvedRoles; + + machineServiceConfig = (serviceConfig.machines.${machineName} or { }).config or { }; + globalConfig = serviceConfig.config or { }; + + # TODO: maybe optimize this dont lookup the role in inverse roles. Imports are not lazy + roleModules = builtins.map ( + role: + let + path = "${clan-core.clanModules.${moduleName}}/roles/${role}.nix"; + in + if builtins.pathExists path then + path + else + throw "Role doesnt have a module: ${role}. Path: ${path} not found." + ) inverseRoles.${machineName} or [ ]; + in + if isInService then + acc2 + ++ [ + { + imports = [ clan-core.clanModules.${moduleName} ] ++ roleModules; + config.clan.${moduleName} = lib.mkMerge [ + globalConfig + machineServiceConfig + ]; + } + { + config.clan.inventory.services.${moduleName}.${instanceName} = { + roles = resolvedRoles; + # TODO: Add inverseRoles to the service config if needed + # inherit inverseRoles; + }; + } + ] + else + acc2 + ) [ ] serviceConfigs) + ) [ ] inventory.services + ) inventory.machines; +in +machines diff --git a/lib/inventory/build-inventory/interface.nix b/lib/inventory/build-inventory/interface.nix new file mode 100644 index 00000000..c399d2af --- /dev/null +++ b/lib/inventory/build-inventory/interface.nix @@ -0,0 +1,121 @@ +{ config, lib, ... }: +let + t = lib.types; + + metaOptions = { + name = lib.mkOption { type = t.str; }; + description = lib.mkOption { + default = null; + type = t.nullOr t.str; + }; + icon = lib.mkOption { + default = null; + type = t.nullOr t.str; + }; + }; + + machineRef = lib.mkOptionType { + name = "machineRef"; + description = "Machine :: [${builtins.concatStringsSep " | " (builtins.attrNames config.machines)}]"; + check = v: lib.isString v && builtins.elem v (builtins.attrNames config.machines); + merge = lib.mergeEqualOption; + }; + + allTags = lib.unique ( + lib.foldlAttrs ( + tags: _: m: + tags ++ m.tags or [ ] + ) [ ] config.machines + ); + + tagRef = lib.mkOptionType { + name = "tagRef"; + description = "Tags :: [${builtins.concatStringsSep " | " allTags}]"; + check = v: lib.isString v && builtins.elem v allTags; + merge = lib.mergeEqualOption; + }; +in +{ + options.assertions = lib.mkOption { + type = t.listOf t.unspecified; + internal = true; + default = [ ]; + }; + config.assertions = lib.foldlAttrs ( + ass1: serviceName: c: + ass1 + ++ lib.foldlAttrs ( + ass2: instanceName: instanceConfig: + let + serviceMachineNames = lib.attrNames instanceConfig.machines; + topLevelMachines = lib.attrNames config.machines; + # All machines must be defined in the top-level machines + assertions = builtins.map (m: { + assertion = builtins.elem m topLevelMachines; + message = "${serviceName}.${instanceName}.machines.${m}. Should be one of [ ${builtins.concatStringsSep " | " topLevelMachines} ]"; + }) serviceMachineNames; + in + ass2 ++ assertions + ) [ ] c + ) [ ] config.services; + + options.meta = metaOptions; + + options.machines = lib.mkOption { + default = { }; + type = t.attrsOf ( + t.submodule { + options = { + inherit (metaOptions) name description icon; + tags = lib.mkOption { + default = [ ]; + apply = lib.unique; + type = t.listOf t.str; + }; + }; + } + ); + }; + + options.services = lib.mkOption { + default = { }; + type = t.attrsOf ( + t.attrsOf ( + t.submodule { + options.meta = metaOptions; + options.config = lib.mkOption { + default = { }; + type = t.anything; + }; + options.machines = lib.mkOption { + default = { }; + type = t.attrsOf ( + t.submodule { + options.config = lib.mkOption { + default = { }; + type = t.anything; + }; + } + ); + }; + options.roles = lib.mkOption { + default = { }; + type = t.attrsOf ( + t.submodule { + options.machines = lib.mkOption { + default = [ ]; + type = t.listOf machineRef; + }; + options.tags = lib.mkOption { + default = [ ]; + apply = lib.unique; + type = t.listOf tagRef; + }; + } + ); + }; + } + ) + ); + }; +} diff --git a/lib/inventory/default.nix b/lib/inventory/default.nix new file mode 100644 index 00000000..78a4c1ac --- /dev/null +++ b/lib/inventory/default.nix @@ -0,0 +1,5 @@ +{ lib, clan-core }: +{ + buildInventory = import ./build-inventory { inherit lib clan-core; }; + interface = ./build-inventory/interface.nix; +} diff --git a/lib/inventory/example.nix b/lib/inventory/example.nix new file mode 100644 index 00000000..50b41792 --- /dev/null +++ b/lib/inventory/example.nix @@ -0,0 +1,38 @@ +{ self, ... }: +self.lib.buildClan { + # Name of the clan in the UI, should be unique + meta.name = "Inventory clan"; + + # Should usually point to the directory of flake.nix + directory = self; + + inventory = { + services = { + borgbackup.instance_1 = { + roles.server.machines = [ "backup_server" ]; + roles.client.tags = [ "backup" ]; + }; + }; + }; + + # merged with + machines = { + "backup_server" = { + clan.tags = [ "all" ]; + }; + "client_1_machine" = { + clan.tags = [ + "all" + "backup" + ]; + }; + "client_2_machine" = { + clan.tags = [ + "all" + "backup" + ]; + # Name of the machine in the UI + clan.meta.name = "camina"; + }; + }; +} diff --git a/inventory/flake-module.nix b/lib/inventory/flake-module.nix similarity index 99% rename from inventory/flake-module.nix rename to lib/inventory/flake-module.nix index 65c6c66f..8778e465 100644 --- a/inventory/flake-module.nix +++ b/lib/inventory/flake-module.nix @@ -13,6 +13,7 @@ mkdir -p $out ''; }; + devShells.inventory-schema = pkgs.mkShell { inputsFrom = [ config.packages.inventory-schema ]; }; checks.inventory-schema-checks = pkgs.stdenv.mkDerivation { diff --git a/inventory/src/cue.mod/module.cue b/lib/inventory/src/cue.mod/module.cue similarity index 100% rename from inventory/src/cue.mod/module.cue rename to lib/inventory/src/cue.mod/module.cue diff --git a/inventory/src/root.cue b/lib/inventory/src/root.cue similarity index 64% rename from inventory/src/root.cue rename to lib/inventory/src/root.cue index 3034dab8..da64c1b2 100644 --- a/inventory/src/root.cue +++ b/lib/inventory/src/root.cue @@ -1,8 +1,7 @@ package inventory import ( - "clan.lol/inventory/services" - "clan.lol/inventory/machines" + "clan.lol/inventory/schema" ) @jsonschema(schema="http://json-schema.org/schema#") @@ -16,9 +15,9 @@ import ( icon?: string } - // A map of services - services.#service + // // A map of services + schema.#service - // A map of machines - machines.#machine + // // A map of machines + schema.#machine } diff --git a/inventory/src/services/services.cue b/lib/inventory/src/schema/schema.cue similarity index 65% rename from inventory/src/services/services.cue rename to lib/inventory/src/schema/schema.cue index 6949c81b..81d197b6 100644 --- a/inventory/src/services/services.cue +++ b/lib/inventory/src/schema/schema.cue @@ -1,22 +1,29 @@ -package services +package schema -#ServiceRole: "server" | "client" | "both" +#machine: machines: [string]: { + name: string, + description?: string, + icon?: string + tags: [...string] +} -#service: services: [string]: { +#role: string + +#service: services: [string]: [string]: { // Required meta fields meta: { name: string, icon?: string description?: string, }, - // Required module specifies the behavior of the service. - module: string, - // We moved the machine sepcific config to "machines". // It may be moved back depending on what makes more sense in the future. - machineConfig: { + roles: [#role]: { + machines: [...string], + tags: [...string], + } + machines: { [string]: { - roles?: [ ...#ServiceRole ], config?: { ... } @@ -29,4 +36,4 @@ package services // It declares the interface how the service can be configured. ... } -} +} \ No newline at end of file diff --git a/lib/inventory/src/tests/borgbackup.json b/lib/inventory/src/tests/borgbackup.json new file mode 100644 index 00000000..7e02bde5 --- /dev/null +++ b/lib/inventory/src/tests/borgbackup.json @@ -0,0 +1,53 @@ +{ + "machines": { + "camina_machine": { + "name": "camina", + "tags": ["laptop"] + }, + "vyr_machine": { + "name": "vyr" + }, + "vi_machine": { + "name": "vi", + "tags": ["laptop"] + } + }, + "meta": { + "name": "kenjis clan" + }, + "services": { + "borgbackup": { + "instance_1": { + "meta": { + "name": "My backup" + }, + "roles": { + "server": { + "machines": ["vyr_machine"] + }, + "client": { + "machines": ["vyr_machine"], + "tags": ["laptop"] + } + }, + "machines": {}, + "config": {} + }, + "instance_2": { + "meta": { + "name": "My backup" + }, + "roles": { + "server": { + "machines": ["vi_machine"] + }, + "client": { + "machines": ["camina_machine"] + } + }, + "machines": {}, + "config": {} + } + } + } +} diff --git a/lib/inventory/src/tests/syncthing.json b/lib/inventory/src/tests/syncthing.json new file mode 100644 index 00000000..e7ed8af6 --- /dev/null +++ b/lib/inventory/src/tests/syncthing.json @@ -0,0 +1,47 @@ +{ + "machines": { + "camina_machine": { + "name": "camina" + }, + "vyr_machine": { + "name": "vyr" + }, + "vi_machine": { + "name": "vi" + } + }, + "meta": { + "name": "kenjis clan" + }, + "services": { + "syncthing": { + "instance_1": { + "meta": { + "name": "My sync" + }, + "roles": { + "peer": { + "machines": ["vyr_machine", "vi_machine", "camina_machine"] + } + }, + "machines": {}, + "config": { + "folders": { + "test": { + "path": "~/data/docs", + "devices": ["camina_machine", "vyr_machine", "vi_machine"] + }, + "videos": { + "path": "~/data/videos", + "devices": ["camina_machine", "vyr_machine"] + }, + "playlist": { + "path": "~/data/playlist", + "devices": ["camina_machine", "vi_machine"] + } + } + } + } + } + } +} diff --git a/lib/inventory/src/tests/zerotier.json b/lib/inventory/src/tests/zerotier.json new file mode 100644 index 00000000..f35e2ab4 --- /dev/null +++ b/lib/inventory/src/tests/zerotier.json @@ -0,0 +1,36 @@ +{ + "machines": { + "camina_machine": { + "name": "camina" + }, + "vyr_machine": { + "name": "vyr" + }, + "vi_machine": { + "name": "vi" + } + }, + "meta": { + "name": "kenjis clan" + }, + "services": { + "zerotier": { + "instance_1": { + "meta": { + "name": "My Network" + }, + "roles": { + "controller": { "machines": ["vyr_machine"] }, + "moon": { "machines": ["vyr_machine"] }, + "peer": { "machines": ["vi_machine", "camina_machine"] } + }, + "machines": { + "vyr_machine": { + "config": {} + } + }, + "config": {} + } + } + } +} diff --git a/nixosModules/clanCore/default.nix b/nixosModules/clanCore/default.nix index 9926155c..c9b7ff3e 100644 --- a/nixosModules/clanCore/default.nix +++ b/nixosModules/clanCore/default.nix @@ -14,5 +14,8 @@ ./vm.nix ./wayland-proxy-virtwl.nix ./zerotier + # Inventory + ./inventory/interface.nix + ./meta/interface.nix ]; } diff --git a/nixosModules/clanCore/inventory/interface.nix b/nixosModules/clanCore/inventory/interface.nix new file mode 100644 index 00000000..895a1599 --- /dev/null +++ b/nixosModules/clanCore/inventory/interface.nix @@ -0,0 +1,35 @@ +{ lib, ... }: +let + # { + # roles = { + # client = { + # machines = [ + # "camina_machine" + # "vi_machine" + # ]; + # }; + # server = { + # machines = [ "vyr_machine" ]; + # }; + # }; + # } + instanceOptions = lib.types.submodule { + options.roles = lib.mkOption { type = lib.types.attrsOf machinesList; }; + }; + + # { + # machines = [ + # "camina_machine" + # "vi_machine" + # "vyr_machine" + # ]; + # } + machinesList = lib.types.submodule { + options.machines = lib.mkOption { type = lib.types.listOf lib.types.str; }; + }; +in +{ + options.clan.inventory.services = lib.mkOption { + type = lib.types.attrsOf (lib.types.attrsOf instanceOptions); + }; +} diff --git a/nixosModules/clanCore/meta/interface.nix b/nixosModules/clanCore/meta/interface.nix new file mode 100644 index 00000000..3b44046e --- /dev/null +++ b/nixosModules/clanCore/meta/interface.nix @@ -0,0 +1,10 @@ +{ lib, ... }: +let + optStr = lib.types.nullOr lib.types.str; +in +{ + options.clan.meta.name = lib.mkOption { type = lib.types.str; }; + options.clan.meta.description = lib.mkOption { type = optStr; }; + options.clan.meta.icon = lib.mkOption { type = optStr; }; + options.clan.tags = lib.mkOption { type = lib.types.listOf lib.types.str; }; +} diff --git a/templates/new-clan/flake.nix b/templates/new-clan/flake.nix index 7fb1361e..8ae5055d 100644 --- a/templates/new-clan/flake.nix +++ b/templates/new-clan/flake.nix @@ -11,7 +11,19 @@ # Usage see: https://docs.clan.lol clan = clan-core.lib.buildClan { directory = self; - clanName = "__CHANGE_ME__"; # Ensure this is internet wide unique. + meta.name = "__CHANGE_ME__"; # Ensure this is internet wide unique. + + # Distributed services, uncomment to enable. + # inventory = { + # services = { + # # This example configures a BorgBackup service + # # Check: https://docs.clan.lol/reference/clanModules which ones are available in Inventory + # borgbackup.instance_1 = { + # roles.server.machines = [ "jon" ]; + # roles.client.machines = [ "sara" ]; + # }; + # }; + # }; # Prerequisite: boot into the installer # See: https://docs.clan.lol/getting-started/installer