WIP: Settings/Inventory: deprecate settings.json #1672
12
inventory.json
Normal file
12
inventory.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"machines": {
|
||||
"foo": {
|
||||
"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"
|
||||
# Is recursively applied. Any explicit nix will override.
|
||||
then
|
||||
lib.mkDefault (builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
|
||||
(builtins.fromJSON (builtins.readFile "${directory}/inventory.json"))
|
||||
else
|
||||
{ }
|
||||
)
|
||||
|
@ -67,11 +67,27 @@ let
|
|||
"clan"
|
||||
"tags"
|
||||
] [ ] config;
|
||||
|
||||
system = lib.attrByPath [
|
||||
"nixpkgs"
|
||||
"hostSystem"
|
||||
] null config;
|
||||
}
|
||||
) machines;
|
||||
}
|
||||
|
||||
# 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
|
||||
(if clanName != null then { meta.name = clanName; } else { })
|
||||
|
@ -91,14 +107,24 @@ let
|
|||
|
||||
machineSettings =
|
||||
machineName:
|
||||
let
|
||||
warn = lib.warn ''
|
||||
Usage of Settings.json will be deprecated
|
||||
!!! Consider migrating to 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
|
||||
# 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
|
||||
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
|
||||
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 =
|
||||
|
|
|
@ -40,72 +40,81 @@ let
|
|||
# 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;
|
||||
machineName: machineConfig:
|
||||
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
|
||||
);
|
||||
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;
|
||||
# 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 { };
|
||||
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 "Module doesn't have role: '${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
|
||||
# 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 "Module doesn't have role: '${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)
|
||||
)
|
||||
# Machine configs
|
||||
[
|
||||
# Default machine config
|
||||
(lib.optionalAttrs (machineConfig.system or null != null) {
|
||||
config.nixpkgs.hostPlatform = machineConfig.system;
|
||||
})
|
||||
]
|
||||
inventory.services
|
||||
) inventory.machines or { };
|
||||
in
|
||||
machines
|
||||
|
|
|
@ -41,23 +41,34 @@ in
|
|||
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;
|
||||
config.assertions =
|
||||
let
|
||||
serviceAssertions = 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;
|
||||
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;
|
||||
|
||||
|
@ -72,6 +83,10 @@ in
|
|||
apply = lib.unique;
|
||||
type = t.listOf t.str;
|
||||
};
|
||||
system = lib.mkOption {
|
||||
default = null;
|
||||
type = t.nullOr t.str;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ self.lib.buildClan {
|
|||
};
|
||||
};
|
||||
};
|
||||
# inventory = builtins.fromJSON (builtins.readFile "${self}/inventory.json");
|
||||
|
||||
# merged with
|
||||
machines = {
|
||||
|
|
|
@ -22,7 +22,7 @@ package schema
|
|||
machines: [...string],
|
||||
tags: [...string],
|
||||
}
|
||||
machines: {
|
||||
machines?: {
|
||||
[string]: {
|
||||
config?: {
|
||||
...
|
||||
|
@ -30,8 +30,8 @@ package schema
|
|||
}
|
||||
},
|
||||
|
||||
// Configuration for the service
|
||||
config: {
|
||||
// Global Configuration for the service
|
||||
config?: {
|
||||
// Schema depends on the module.
|
||||
// It declares the interface how the service can be configured.
|
||||
...
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
@ -10,9 +9,7 @@ from typing import Any, get_origin
|
|||
|
||||
from clan_cli.cmd import run
|
||||
from clan_cli.completions import add_dynamic_completer, complete_machines
|
||||
from clan_cli.dirs import machine_settings_file
|
||||
from clan_cli.errors import ClanError
|
||||
from clan_cli.git import commit_file
|
||||
from clan_cli.nix import nix_eval
|
||||
|
||||
script_dir = Path(__file__).parent
|
||||
|
@ -177,17 +174,17 @@ def get_or_set_option(args: argparse.Namespace) -> None:
|
|||
with open(args.options_file) as f:
|
||||
options = json.load(f)
|
||||
# compute settings json file location
|
||||
if args.settings_file is None:
|
||||
settings_file = machine_settings_file(Path(args.flake), args.machine)
|
||||
else:
|
||||
settings_file = args.settings_file
|
||||
# if args.settings_file is None:
|
||||
# settings_file = machine_settings_file(Path(args.flake), args.machine)
|
||||
# else:
|
||||
# settings_file = args.settings_file
|
||||
# set the option with the given value
|
||||
set_option(
|
||||
flake_dir=Path(args.flake),
|
||||
option=args.option,
|
||||
value=args.value,
|
||||
options=options,
|
||||
settings_file=settings_file,
|
||||
# settings_file=settings_file,
|
||||
option_description=args.option,
|
||||
show_trace=args.show_trace,
|
||||
)
|
||||
|
@ -253,7 +250,7 @@ def set_option(
|
|||
option: str,
|
||||
value: Any,
|
||||
options: dict,
|
||||
settings_file: Path,
|
||||
# settings_file: Path,
|
||||
option_description: str = "",
|
||||
show_trace: bool = False,
|
||||
) -> None:
|
||||
|
@ -286,25 +283,25 @@ def set_option(
|
|||
current[option_path_store[-1]] = casted
|
||||
|
||||
# check if there is an existing config file
|
||||
if os.path.exists(settings_file):
|
||||
with open(settings_file) as f:
|
||||
current_config = json.load(f)
|
||||
else:
|
||||
current_config = {}
|
||||
# if os.path.exists(settings_file):
|
||||
# with open(settings_file) as f:
|
||||
# current_config = json.load(f)
|
||||
# else:
|
||||
# current_config = {}
|
||||
|
||||
# merge and save the new config file
|
||||
new_config = merge(current_config, result)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(new_config, f, indent=2)
|
||||
print(file=f) # add newline at the end of the file to make git happy
|
||||
# new_config = merge(current_config, result)
|
||||
# settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# with open(settings_file, "w") as f:
|
||||
# json.dump(new_config, f, indent=2)
|
||||
# print(file=f) # add newline at the end of the file to make git happy
|
||||
|
||||
if settings_file.resolve().is_relative_to(flake_dir):
|
||||
commit_file(
|
||||
settings_file,
|
||||
repo_dir=flake_dir,
|
||||
commit_message=f"Set option {option_description}",
|
||||
)
|
||||
# if settings_file.resolve().is_relative_to(flake_dir):
|
||||
# commit_file(
|
||||
# settings_file,
|
||||
# repo_dir=flake_dir,
|
||||
# commit_message=f"Set option {option_description}",
|
||||
# )
|
||||
|
||||
|
||||
# takes a (sub)parser and configures it
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from clan_cli.cmd import Log, run
|
||||
from clan_cli.dirs import machine_settings_file, nixpkgs_source, specific_machine_dir
|
||||
from clan_cli.errors import ClanError, ClanHttpError
|
||||
from clan_cli.git import commit_file
|
||||
from clan_cli.dirs import nixpkgs_source
|
||||
from clan_cli.nix import nix_eval
|
||||
|
||||
|
||||
|
@ -21,13 +18,15 @@ def verify_machine_config(
|
|||
Returns a tuple of (success, error_message)
|
||||
"""
|
||||
if config is None:
|
||||
config = config_for_machine(flake_dir, machine_name)
|
||||
# TODO: @davHau - Migrate this to inventory.
|
||||
config = {}
|
||||
# config = config_for_machine(flake_dir, machine_name)
|
||||
flake = flake_dir
|
||||
with NamedTemporaryFile(mode="w", dir=flake) as clan_machine_settings_file:
|
||||
json.dump(config, clan_machine_settings_file, indent=2)
|
||||
clan_machine_settings_file.seek(0)
|
||||
env = os.environ.copy()
|
||||
env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
|
||||
# env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
|
||||
cmd = nix_eval(
|
||||
flags=[
|
||||
"--show-trace",
|
||||
|
@ -72,37 +71,37 @@ def verify_machine_config(
|
|||
return None
|
||||
|
||||
|
||||
def config_for_machine(flake_dir: Path, machine_name: str) -> dict:
|
||||
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
|
||||
if not specific_machine_dir(flake_dir, machine_name).exists():
|
||||
raise ClanHttpError(
|
||||
msg=f"Machine {machine_name} not found. Create the machine first`",
|
||||
status_code=404,
|
||||
)
|
||||
settings_path = machine_settings_file(flake_dir, machine_name)
|
||||
if not settings_path.exists():
|
||||
return {}
|
||||
with open(settings_path) as f:
|
||||
return json.load(f)
|
||||
# def config_for_machine(flake_dir: Path, machine_name: str) -> dict:
|
||||
# read the config from a json file located at {flake}/machines/{machine_name}/settings.json
|
||||
# if not specific_machine_dir(flake_dir, machine_name).exists():
|
||||
# raise ClanHttpError(
|
||||
# msg=f"Machine {machine_name} not found. Create the machine first`",
|
||||
# status_code=404,
|
||||
# )
|
||||
# settings_path = machine_settings_file(flake_dir, machine_name)
|
||||
# if not settings_path.exists():
|
||||
# return {}
|
||||
# with open(settings_path) as f:
|
||||
# return json.load(f)
|
||||
|
||||
|
||||
def set_config_for_machine(flake_dir: Path, machine_name: str, config: dict) -> None:
|
||||
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
|
||||
if not re.match(hostname_regex, machine_name):
|
||||
raise ClanError("Machine name must be a valid hostname")
|
||||
if "networking" in config and "hostName" in config["networking"]:
|
||||
if machine_name != config["networking"]["hostName"]:
|
||||
raise ClanHttpError(
|
||||
msg="Machine name does not match the 'networking.hostName' setting in the config",
|
||||
status_code=400,
|
||||
)
|
||||
config["networking"]["hostName"] = machine_name
|
||||
# create machine folder if it doesn't exist
|
||||
# write the config to a json file located at {flake}/machines/{machine_name}/settings.json
|
||||
settings_path = machine_settings_file(flake_dir, machine_name)
|
||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_path, "w") as f:
|
||||
json.dump(config, f)
|
||||
# def set_config_for_machine(flake_dir: Path, machine_name: str, config: dict) -> None:
|
||||
# hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
|
||||
# if not re.match(hostname_regex, machine_name):
|
||||
# raise ClanError("Machine name must be a valid hostname")
|
||||
# if "networking" in config and "hostName" in config["networking"]:
|
||||
# if machine_name != config["networking"]["hostName"]:
|
||||
# raise ClanHttpError(
|
||||
# msg="Machine name does not match the 'networking.hostName' setting in the config",
|
||||
# status_code=400,
|
||||
# )
|
||||
# config["networking"]["hostName"] = machine_name
|
||||
# # create machine folder if it doesn't exist
|
||||
# # write the config to a json file located at {flake}/machines/{machine_name}/settings.json
|
||||
# settings_path = machine_settings_file(flake_dir, machine_name)
|
||||
# settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# with open(settings_path, "w") as f:
|
||||
# json.dump(config, f)
|
||||
|
||||
if flake_dir is not None:
|
||||
commit_file(settings_path, flake_dir)
|
||||
# if flake_dir is not None:
|
||||
# commit_file(settings_path, flake_dir)
|
||||
|
|
|
@ -103,10 +103,6 @@ def specific_machine_dir(flake_dir: Path, machine: str) -> Path:
|
|||
return machines_dir(flake_dir) / machine
|
||||
|
||||
|
||||
def machine_settings_file(flake_dir: Path, machine: str) -> Path:
|
||||
return specific_machine_dir(flake_dir, machine) / "settings.json"
|
||||
|
||||
|
||||
def module_root() -> Path:
|
||||
return Path(__file__).parent
|
||||
|
||||
|
|
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()
|
||||
},
|
||||
)
|
|
@ -1,30 +1,81 @@
|
|||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from clan_cli.api import API
|
||||
from clan_cli.config.machine import set_config_for_machine
|
||||
from clan_cli.errors import ClanError
|
||||
from clan_cli.inventory import Inventory, Machine, dataclass_to_dict
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MachineCreateRequest:
|
||||
name: str
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
@API.register
|
||||
def create_machine(flake_dir: str | Path, machine: MachineCreateRequest) -> None:
|
||||
set_config_for_machine(Path(flake_dir), machine.name, machine.config)
|
||||
def create_machine(flake_dir: str | Path, machine: Machine) -> None:
|
||||
hostname_regex = r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$"
|
||||
if not re.match(hostname_regex, machine.name):
|
||||
raise ClanError("Machine name must be a valid hostname")
|
||||
|
||||
inventory = Inventory(machines={}, services={})
|
||||
|
||||
inventory_file = Path(flake_dir) / "inventory.json"
|
||||
if inventory_file.exists():
|
||||
with open(inventory_file) as f:
|
||||
try:
|
||||
res = json.load(f)
|
||||
inventory = Inventory.from_dict(res)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ClanError(f"Error decoding inventory file: {e}")
|
||||
|
||||
inventory.machines.update({machine.name: machine})
|
||||
|
||||
with open(inventory_file, "w") as g:
|
||||
d = dataclass_to_dict(inventory)
|
||||
json.dump(d, g, indent=2)
|
||||
|
||||
|
||||
def create_command(args: argparse.Namespace) -> None:
|
||||
create_machine(args.flake, MachineCreateRequest(args.machine, dict()))
|
||||
create_machine(
|
||||
args.flake,
|
||||
Machine(
|
||||
name=args.machine,
|
||||
system=args.system,
|
||||
description=args.description,
|
||||
tags=args.tags,
|
||||
icon=args.icon,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def register_create_parser(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("machine", type=str)
|
||||
parser.set_defaults(func=create_command)
|
||||
|
||||
parser.add_argument(
|
||||
"--system",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Host platform to use. i.e. 'x86_64-linux' or 'aarch64-darwin' etc.",
|
||||
metavar="PLATFORM",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default=None,
|
||||
help="A description of the machine.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--icon",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to an icon to use for the machine. - Must be a path to icon file relative to the flake directory, or a public url.",
|
||||
metavar="PATH",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tags",
|
||||
nargs="+",
|
||||
default=[],
|
||||
help="Tags to associate with the machine. Can be used to assign multiple machines to services.",
|
||||
)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import fileinput
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
@ -86,10 +85,10 @@ def generate_flake(
|
|||
print(line, end="")
|
||||
|
||||
# generate machines from machineConfigs
|
||||
for machine_name, machine_config in machine_configs.items():
|
||||
settings_path = flake / "machines" / machine_name / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings_path.write_text(json.dumps(machine_config, indent=2))
|
||||
# for machine_name, machine_config in machine_configs.items():
|
||||
# settings_path = flake / "machines" / machine_name / "settings.json"
|
||||
# settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# settings_path.write_text(json.dumps(machine_config, indent=2))
|
||||
|
||||
if "/tmp" not in str(os.environ.get("HOME")):
|
||||
log.warning(
|
||||
|
@ -148,10 +147,10 @@ def create_flake(
|
|||
substitute(flake / "machines" / machine_name / "default.nix", flake)
|
||||
|
||||
# generate machines from machineConfigs
|
||||
for machine_name, machine_config in machine_configs.items():
|
||||
settings_path = flake / "machines" / machine_name / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings_path.write_text(json.dumps(machine_config, indent=2))
|
||||
# for machine_name, machine_config in machine_configs.items():
|
||||
# settings_path = flake / "machines" / machine_name / "settings.json"
|
||||
# settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# settings_path.write_text(json.dumps(machine_config, indent=2))
|
||||
|
||||
# in the flake.nix file replace the string __CLAN_URL__ with the the clan flake
|
||||
# provided by get_test_flake_toplevel
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import pytest
|
||||
from fixtures_flakes import FlakeForTest
|
||||
|
||||
from clan_cli.config.machine import (
|
||||
config_for_machine,
|
||||
set_config_for_machine,
|
||||
verify_machine_config,
|
||||
)
|
||||
from clan_cli.config.schema import machine_schema
|
||||
from clan_cli.machines.create import MachineCreateRequest, create_machine
|
||||
from clan_cli.inventory import Machine
|
||||
from clan_cli.machines.create import create_machine
|
||||
from clan_cli.machines.list import list_machines
|
||||
|
||||
|
||||
|
@ -22,14 +18,12 @@ def test_create_machine_on_minimal_clan(test_flake_minimal: FlakeForTest) -> Non
|
|||
assert list_machines(test_flake_minimal.path) == []
|
||||
create_machine(
|
||||
test_flake_minimal.path,
|
||||
MachineCreateRequest(
|
||||
name="foo", config=dict(nixpkgs=dict(hostSystem="x86_64-linux"))
|
||||
),
|
||||
Machine(name="foo", system="x86_64-linux"),
|
||||
)
|
||||
assert list_machines(test_flake_minimal.path) == ["foo"]
|
||||
set_config_for_machine(
|
||||
test_flake_minimal.path, "foo", dict(services=dict(openssh=dict(enable=True)))
|
||||
)
|
||||
config = config_for_machine(test_flake_minimal.path, "foo")
|
||||
assert config["services"]["openssh"]["enable"]
|
||||
assert verify_machine_config(test_flake_minimal.path, "foo") is None
|
||||
# set_config_for_machine(
|
||||
# test_flake_minimal.path, "foo", dict(services=dict(openssh=dict(enable=True)))
|
||||
# )
|
||||
# config = config_for_machine(test_flake_minimal.path, "foo")
|
||||
# assert config["services"]["openssh"]["enable"]
|
||||
# assert verify_machine_config(test_flake_minimal.path, "foo") is None
|
||||
|
|
Loading…
Reference in New Issue
Block a user