diff --git a/lib/inventory/flake-module.nix b/lib/inventory/flake-module.nix index 3a5b8bc73..9a01abac9 100644 --- a/lib/inventory/flake-module.nix +++ b/lib/inventory/flake-module.nix @@ -97,6 +97,18 @@ in cp schema.json $out ''; }; + + legacyPackages.schemas = ( + import ./schemas { + inherit + pkgs + self + lib + self' + ; + } + ); + packages.inventory-schema-pretty = pkgs.stdenv.mkDerivation { name = "inventory-schema-pretty"; buildInputs = [ pkgs.cue ]; diff --git a/lib/inventory/schemas/default.nix b/lib/inventory/schemas/default.nix new file mode 100644 index 000000000..5ad5291ec --- /dev/null +++ b/lib/inventory/schemas/default.nix @@ -0,0 +1,52 @@ +{ + self, + self', + pkgs, + lib, + ... +}: +let + includeDefaults = true; + + # { mName :: { roleName :: Options } } + modulesRolesOptions = self.lib.evalClanModulesWithRoles self.clanModules; + modulesSchema = lib.mapAttrs ( + _moduleName: rolesOptions: + lib.mapAttrs (_roleName: options: jsonWithoutHeader.parseOptions options { }) rolesOptions + ) modulesRolesOptions; + + jsonLib = self.lib.jsonschema { inherit includeDefaults; }; + + jsonWithoutHeader = self.lib.jsonschema { + inherit includeDefaults; + header = { }; + }; + + inventorySchema = jsonLib.parseModule (import ../build-inventory/interface.nix); + renderSchema = pkgs.writers.writePython3Bin "render-schema" { + flakeIgnore = [ + "F401" + "E501" + ]; + } ./render_schema.py; +in +{ + inherit inventorySchema modulesSchema renderSchema; + + inventory = + pkgs.runCommand "rendered" + { + buildInputs = [ + pkgs.python3 + self'.packages.clan-cli + ]; + } + '' + export INVENTORY_SCHEMA_PATH=${builtins.toFile "inventory-schema.json" (builtins.toJSON inventorySchema)} + export MODULES_SCHEMA_PATH=${builtins.toFile "modules-schema.json" (builtins.toJSON modulesSchema)} + + mkdir $out + # The python script will place the schemas in the output directory + exec python3 ${renderSchema}/bin/render-schema + ''; +} diff --git a/lib/inventory/schemas/render_schema.py b/lib/inventory/schemas/render_schema.py new file mode 100644 index 000000000..f18bd1e61 --- /dev/null +++ b/lib/inventory/schemas/render_schema.py @@ -0,0 +1,163 @@ +""" +Python script to join the abstract inventory schema, with the concrete clan modules +Inventory has slots which are 'Any' type. +We dont want to evaluate the clanModules interface in nix, when evaluating the inventory +""" + +import json +import os +from pathlib import Path +from typing import Any + +from clan_cli.errors import ClanError + +# Get environment variables +INVENTORY_SCHEMA_PATH = Path(os.environ["INVENTORY_SCHEMA_PATH"]) + +# { [moduleName] :: { [roleName] :: SCHEMA }} +MODULES_SCHEMA_PATH = Path(os.environ["MODULES_SCHEMA_PATH"]) + +OUT = os.environ.get("out") + +if not INVENTORY_SCHEMA_PATH: + msg = f"Environment variables are not set correctly: INVENTORY_SCHEMA_PATH={INVENTORY_SCHEMA_PATH}." + raise ClanError(msg) + +if not MODULES_SCHEMA_PATH: + msg = f"Environment variables are not set correctly: MODULES_SCHEMA_PATH={MODULES_SCHEMA_PATH}." + raise ClanError(msg) + +if not OUT: + msg = f"Environment variables are not set correctly: OUT={OUT}." + raise ClanError(msg) + + +def service_roles_to_schema( + schema: dict[str, Any], + service_name: str, + roles: list[str], + roles_schemas: list[dict[str, Any]], + # Original service properties: {'config': Schema, 'machines': Schema, 'meta': Schema, 'extraModules': Schema, ...?} + orig: dict[str, Any], +) -> dict[str, Any]: + """ + Add roles to the service schema + """ + role_schema = {} + for role in roles: + role_schema[role] = { + "type": "object", + "additionalProperties": False, + "properties": { + **orig["roles"]["additionalProperties"]["properties"], + "config": { + "title": f"{service_name}-config-role-{role}", + "oneOf": roles_schemas, + "type": "object", + "default": {}, + "additionalProperties": False, + }, + }, + } + + machines_schema = { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + **orig["machines"]["additionalProperties"]["properties"], + "config": { + "title": f"{service_name}-config", + "oneOf": roles_schemas, + "type": "object", + "default": {}, + "additionalProperties": False, + }, + }, + }, + } + + services["properties"][service_name] = { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": False, + "properties": { + # Original inventory schema + **orig, + # Inject the roles schemas + "roles": { + "title": f"{service_name}-roles", + "type": "object", + "properties": role_schema, + "additionalProperties": False, + }, + "machines": machines_schema, + "config": { + "title": f"{service_name}-config", + "oneOf": roles_schemas, + "type": "object", + "default": {}, + "additionalProperties": False, + }, + }, + }, + } + + return schema + + +if __name__ == "__main__": + print("Joining inventory schema with modules schema") + print(f"Inventory schema path: {INVENTORY_SCHEMA_PATH}") + print(f"Modules schema path: {MODULES_SCHEMA_PATH}") + + modules_schema = {} + with Path.open(MODULES_SCHEMA_PATH) as f: + modules_schema = json.load(f) + + inventory_schema = {} + with Path.open(INVENTORY_SCHEMA_PATH) as f: + inventory_schema = json.load(f) + + services = inventory_schema["properties"]["services"] + original_service_props = services["additionalProperties"]["additionalProperties"][ + "properties" + ].copy() + # Init the outer services schema + # Properties (service names) will be filled in the next step + services = { + "type": "object", + "properties": { + # Service names + }, + "additionalProperties": False, + } + + for module_name, roles_schema in modules_schema.items(): + # collect all the roles for the service + roles_schemas = [] + for role_name, role_schema in roles_schema.items(): + role_schema["title"] = f"{module_name}-config-role-{role_name}" + roles_schemas.append(role_schema) + + # Add the roles schemas to the service schema + if roles_schemas: + roles = list(roles_schema.keys()) + + services = service_roles_to_schema( + services, + module_name, + roles, + roles_schemas, + original_service_props, + ) + + inventory_schema["properties"]["services"] = services + + outpath = Path(OUT) + with (outpath / "schema.json").open("w") as f: + json.dump(inventory_schema, f, indent=2) + + with (outpath / "modules_schemas.json").open("w") as f: + json.dump(modules_schema, f, indent=2)