Inventory: add system and sample machine #1675
12
inventory.json
Normal file
12
inventory.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"machines": {
|
||||||
|
"minimal_inventory_machine": {
|
||||||
|
"name": "foo",
|
||||||
|
"system": "x86_64-linux",
|
||||||
|
"description": "A nice thing",
|
||||||
|
"icon": "./path/to/icon.png",
|
||||||
|
"tags": ["1", "2", "3"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {}
|
||||||
|
}
|
@ -40,7 +40,7 @@ let
|
|||||||
builtins.pathExists "${directory}/inventory.json"
|
builtins.pathExists "${directory}/inventory.json"
|
||||||
# Is recursively applied. Any explicit nix will override.
|
# Is recursively applied. Any explicit nix will override.
|
||||||
then
|
then
|
||||||
lib.mkDefault (builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
|
(builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
|
||||||
else
|
else
|
||||||
{ }
|
{ }
|
||||||
)
|
)
|
||||||
@ -67,11 +67,27 @@ let
|
|||||||
"clan"
|
"clan"
|
||||||
"tags"
|
"tags"
|
||||||
] [ ] config;
|
] [ ] config;
|
||||||
|
|
||||||
|
system = lib.attrByPath [
|
||||||
|
"nixpkgs"
|
||||||
|
"hostSystem"
|
||||||
|
] null config;
|
||||||
}
|
}
|
||||||
) machines;
|
) machines;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Will be deprecated
|
# Will be deprecated
|
||||||
{ machines = lib.mapAttrs (_n: _: lib.mkDefault { }) machinesDirs; }
|
{
|
||||||
|
machines = lib.mapAttrs (
|
||||||
|
name: _:
|
||||||
|
# Use mkForce to make sure users migrate to the inventory system.
|
||||||
|
# When the settings.json exists the evaluation will print the deprecation warning.
|
||||||
|
lib.mkForce {
|
||||||
|
inherit name;
|
||||||
|
system = (machineSettings name).nixpkgs.hostSystem or null;
|
||||||
|
}
|
||||||
|
) machinesDirs;
|
||||||
|
}
|
||||||
|
|
||||||
# Deprecated interface
|
# Deprecated interface
|
||||||
(if clanName != null then { meta.name = clanName; } else { })
|
(if clanName != null then { meta.name = clanName; } else { })
|
||||||
@ -91,14 +107,24 @@ let
|
|||||||
|
|
||||||
machineSettings =
|
machineSettings =
|
||||||
machineName:
|
machineName:
|
||||||
|
let
|
||||||
|
warn = lib.warn ''
|
||||||
|
Usage of Settings.json is only supported for test compatibility.
|
||||||
|
!!! Consider using the inventory system. !!!
|
||||||
|
|
||||||
|
File: ${directory + /machines/${machineName}/settings.json}
|
||||||
|
|
||||||
|
If there are still features missing in the inventory system, please open an issue on the clan-core repository.
|
||||||
|
'';
|
||||||
|
in
|
||||||
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
|
# CLAN_MACHINE_SETTINGS_FILE allows to override the settings file temporarily
|
||||||
# This is useful for doing a dry-run before writing changes into the settings.json
|
# This is useful for doing a dry-run before writing changes into the settings.json
|
||||||
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
|
# Using CLAN_MACHINE_SETTINGS_FILE requires passing --impure to nix eval
|
||||||
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
|
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" then
|
||||||
builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
|
warn (builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")))
|
||||||
else
|
else
|
||||||
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
|
lib.optionalAttrs (builtins.pathExists "${directory}/machines/${machineName}/settings.json") (
|
||||||
builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json))
|
warn (builtins.fromJSON (builtins.readFile (directory + /machines/${machineName}/settings.json)))
|
||||||
);
|
);
|
||||||
|
|
||||||
machineImports =
|
machineImports =
|
||||||
|
@ -40,7 +40,7 @@ let
|
|||||||
# For every machine in the inventory, build a NixOS configuration
|
# For every machine in the inventory, build a NixOS configuration
|
||||||
# For each machine generate config, forEach service, if the machine is used.
|
# For each machine generate config, forEach service, if the machine is used.
|
||||||
builtins.mapAttrs (
|
builtins.mapAttrs (
|
||||||
machineName: _:
|
machineName: machineConfig:
|
||||||
lib.foldlAttrs (
|
lib.foldlAttrs (
|
||||||
# [ Modules ], String, { ${instance_name} :: ServiceConfig }
|
# [ Modules ], String, { ${instance_name} :: ServiceConfig }
|
||||||
acc: moduleName: serviceConfigs:
|
acc: moduleName: serviceConfigs:
|
||||||
@ -106,6 +106,12 @@ let
|
|||||||
acc2
|
acc2
|
||||||
) [ ] serviceConfigs)
|
) [ ] serviceConfigs)
|
||||||
) [ ] inventory.services
|
) [ ] inventory.services
|
||||||
|
# Append each machine config
|
||||||
|
++ [
|
||||||
|
(lib.optionalAttrs (machineConfig.system or null != null) {
|
||||||
|
config.nixpkgs.hostPlatform = machineConfig.system;
|
||||||
|
})
|
||||||
|
]
|
||||||
) inventory.machines or { };
|
) inventory.machines or { };
|
||||||
in
|
in
|
||||||
machines
|
machines
|
||||||
|
@ -41,7 +41,9 @@ in
|
|||||||
internal = true;
|
internal = true;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
};
|
};
|
||||||
config.assertions = lib.foldlAttrs (
|
config.assertions =
|
||||||
|
let
|
||||||
|
serviceAssertions = lib.foldlAttrs (
|
||||||
ass1: serviceName: c:
|
ass1: serviceName: c:
|
||||||
ass1
|
ass1
|
||||||
++ lib.foldlAttrs (
|
++ lib.foldlAttrs (
|
||||||
@ -58,6 +60,15 @@ in
|
|||||||
ass2 ++ assertions
|
ass2 ++ assertions
|
||||||
) [ ] c
|
) [ ] c
|
||||||
) [ ] config.services;
|
) [ ] config.services;
|
||||||
|
machineAssertions = map (
|
||||||
|
{ name, value }:
|
||||||
|
{
|
||||||
|
assertion = true;
|
||||||
|
message = "Machine ${name} should define its host system in the inventory. ()";
|
||||||
|
}
|
||||||
|
) (lib.attrsToList (lib.filterAttrs (_n: v: v.system or null == null) config.machines));
|
||||||
|
in
|
||||||
|
machineAssertions ++ serviceAssertions;
|
||||||
|
|
||||||
options.meta = metaOptions;
|
options.meta = metaOptions;
|
||||||
|
|
||||||
@ -72,6 +83,10 @@ in
|
|||||||
apply = lib.unique;
|
apply = lib.unique;
|
||||||
type = t.listOf t.str;
|
type = t.listOf t.str;
|
||||||
};
|
};
|
||||||
|
system = lib.mkOption {
|
||||||
|
default = null;
|
||||||
|
type = t.nullOr t.str;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -22,7 +22,7 @@ package schema
|
|||||||
machines: [...string],
|
machines: [...string],
|
||||||
tags: [...string],
|
tags: [...string],
|
||||||
}
|
}
|
||||||
machines: {
|
machines?: {
|
||||||
[string]: {
|
[string]: {
|
||||||
config?: {
|
config?: {
|
||||||
...
|
...
|
||||||
@ -30,8 +30,8 @@ package schema
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Configuration for the service
|
// Global Configuration for the service
|
||||||
config: {
|
config?: {
|
||||||
// Schema depends on the module.
|
// Schema depends on the module.
|
||||||
// It declares the interface how the service can be configured.
|
// It declares the interface how the service can be configured.
|
||||||
...
|
...
|
||||||
|
@ -67,14 +67,18 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
expr = {
|
expr = {
|
||||||
|
# A machine that includes the backup service should have 3 imports
|
||||||
|
# - one for some service agnostic properties of the machine itself
|
||||||
|
# - One for the service itself (default.nix)
|
||||||
|
# - one for the role (roles/client.nix)
|
||||||
client_1_machine = builtins.length configs.client_1_machine;
|
client_1_machine = builtins.length configs.client_1_machine;
|
||||||
client_2_machine = builtins.length configs.client_2_machine;
|
client_2_machine = builtins.length configs.client_2_machine;
|
||||||
not_used_machine = builtins.length configs.not_used_machine;
|
not_used_machine = builtins.length configs.not_used_machine;
|
||||||
};
|
};
|
||||||
expected = {
|
expected = {
|
||||||
client_1_machine = 2;
|
client_1_machine = 3;
|
||||||
client_2_machine = 2;
|
client_2_machine = 3;
|
||||||
not_used_machine = 0;
|
not_used_machine = 1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
3
machines/minimal_inventory_machine/README.md
Normal file
3
machines/minimal_inventory_machine/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
This settings exists only for testing purpose to make the machine bootable
|
||||||
|
|
||||||
|
It serves as a gap close, until we have the required boot and filesystem modules ready for the inventory.
|
4
machines/minimal_inventory_machine/settings.json
Normal file
4
machines/minimal_inventory_machine/settings.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"fileSystems": { "/": { "device": "/dev/null" } },
|
||||||
|
"boot": { "loader": { "grub": { "device": "/dev/null" } } }
|
||||||
|
}
|
138
pkgs/clan-cli/clan_cli/inventory/__init__.py
Normal file
138
pkgs/clan-cli/clan_cli/inventory/__init__.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass, is_dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_string(s: str) -> str:
|
||||||
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
def dataclass_to_dict(obj: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Utility function to convert dataclasses to dictionaries
|
||||||
|
It converts all nested dataclasses, lists, tuples, and dictionaries to dictionaries
|
||||||
|
|
||||||
|
It does NOT convert member functions.
|
||||||
|
"""
|
||||||
|
if is_dataclass(obj):
|
||||||
|
return {
|
||||||
|
sanitize_string(k): dataclass_to_dict(v)
|
||||||
|
for k, v in asdict(obj).items() # type: ignore
|
||||||
|
}
|
||||||
|
elif isinstance(obj, list | tuple):
|
||||||
|
return [dataclass_to_dict(item) for item in obj]
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
return {sanitize_string(k): dataclass_to_dict(v) for k, v in obj.items()}
|
||||||
|
elif isinstance(obj, Path):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
return sanitize_string(obj)
|
||||||
|
else:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Machine:
|
||||||
|
"""
|
||||||
|
Inventory machine model.
|
||||||
|
|
||||||
|
DO NOT EDIT THIS CLASS.
|
||||||
|
Any changes here must be reflected in the inventory interface file and potentially other nix files.
|
||||||
|
|
||||||
|
- Persisted to the inventory.json file
|
||||||
|
- Source of truth to generate each clan machine.
|
||||||
|
- For hardware deployment, the machine must declare the host system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
system: Literal["x86_64-linux"] | str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "Machine":
|
||||||
|
if "name" not in d:
|
||||||
|
raise ClanError("name not found in machine")
|
||||||
|
|
||||||
|
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
|
||||||
|
if not re.match(hostname_regex, d["name"]):
|
||||||
|
raise ClanError(
|
||||||
|
"Machine name must be a valid hostname",
|
||||||
|
description=f"""Machine name: {d["name"]}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Machine(**d)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MachineServiceConfig:
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceMeta:
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Role:
|
||||||
|
machines: list[str] | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Service:
|
||||||
|
meta: ServiceMeta
|
||||||
|
roles: dict[str, Role]
|
||||||
|
machines: dict[str, MachineServiceConfig] | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "Service":
|
||||||
|
if "meta" not in d:
|
||||||
|
raise ClanError("meta not found in service")
|
||||||
|
|
||||||
|
if "roles" not in d:
|
||||||
|
raise ClanError("roles not found in service")
|
||||||
|
|
||||||
|
return Service(
|
||||||
|
meta=ServiceMeta(**d["meta"]),
|
||||||
|
roles={name: Role(**role) for name, role in d["roles"].items()},
|
||||||
|
machines={
|
||||||
|
name: MachineServiceConfig(**machine)
|
||||||
|
for name, machine in d.get("machines", {}).items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Inventory:
|
||||||
|
machines: dict[str, Machine]
|
||||||
|
services: dict[str, dict[str, Service]]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "Inventory":
|
||||||
|
if "machines" not in d:
|
||||||
|
raise ClanError("machines not found in inventory")
|
||||||
|
|
||||||
|
if "services" not in d:
|
||||||
|
raise ClanError("services not found in inventory")
|
||||||
|
|
||||||
|
return Inventory(
|
||||||
|
machines={
|
||||||
|
name: Machine.from_dict(machine)
|
||||||
|
for name, machine in d["machines"].items()
|
||||||
|
},
|
||||||
|
services={
|
||||||
|
name: {
|
||||||
|
role: Service.from_dict(service)
|
||||||
|
for role, service in services.items()
|
||||||
|
}
|
||||||
|
for name, services in d["services"].items()
|
||||||
|
},
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user