api/machines: allow importing extra modules

- add top-level option `clanImports` to clanCore
- clanImports can be set and checked as any other option
- buildClan resolves the clanImports from the settings.json before calling evalModules to prevent infinite recursions
- new endpoint PUT machines/{name}/schema to allow getting the schema for a specific list of imports
- to retrieve the currently imported modules, cimply do a GET or PU on machines/{name}/config which will return `clanImports` as part of the config

Still missing: get list of available modules
This commit is contained in:
DavHau
2023-10-25 16:36:01 +01:00
parent 1d45d493ef
commit bf176ad277
11 changed files with 234 additions and 81 deletions

View File

@@ -1,4 +1,4 @@
{ nixpkgs, self, lib }: { clan-core, nixpkgs, lib }:
{ directory # The directory containing the machines subdirectory { directory # The directory containing the machines subdirectory
, specialArgs ? { } # Extra arguments to pass to nixosSystem i.e. useful to make self available , specialArgs ? { } # Extra arguments to pass to nixosSystem i.e. useful to make self available
, machines ? { } # allows to include machine-specific modules i.e. machines.${name} = { ... } , machines ? { } # allows to include machine-specific modules i.e. machines.${name} = { ... }
@@ -7,6 +7,9 @@ let
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (builtins.readDir (directory + /machines)); machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (builtins.readDir (directory + /machines));
machineSettings = machineName: machineSettings = machineName:
# 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" != "" if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != ""
then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")) then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
else else
@@ -14,18 +17,33 @@ let
(builtins.fromJSON (builtins.fromJSON
(builtins.readFile (directory + /machines/${machineName}/settings.json))); (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 [ ]);
# TODO: remove default system once we have a hardware-config mechanism # TODO: remove default system once we have a hardware-config mechanism
nixosConfiguration = { system ? "x86_64-linux", name }: nixpkgs.lib.nixosSystem { nixosConfiguration = { system ? "x86_64-linux", name }: nixpkgs.lib.nixosSystem {
modules = [ modules =
self.nixosModules.clanCore let
(machineSettings name) settings = machineSettings name;
(machines.${name} or { }) in
{ (machineImports settings)
clanCore.machineName = name; ++ [
clanCore.clanDir = directory; settings
nixpkgs.hostPlatform = lib.mkForce system; clan-core.nixosModules.clanCore
} (machines.${name} or { })
]; {
clanCore.machineName = name;
clanCore.clanDir = directory;
nixpkgs.hostPlatform = lib.mkForce system;
}
];
inherit specialArgs; inherit specialArgs;
}; };

View File

@@ -1,6 +1,6 @@
{ lib, self, nixpkgs, ... }: { lib, clan-core, nixpkgs, ... }:
{ {
jsonschema = import ./jsonschema { inherit lib; }; jsonschema = import ./jsonschema { inherit lib; };
buildClan = import ./build-clan { inherit lib self nixpkgs; }; buildClan = import ./build-clan { inherit clan-core lib nixpkgs; };
} }

View File

@@ -8,7 +8,7 @@
]; ];
flake.lib = import ./default.nix { flake.lib = import ./default.nix {
inherit lib; inherit lib;
inherit self;
inherit (inputs) nixpkgs; inherit (inputs) nixpkgs;
clan-core = self;
}; };
} }

View File

@@ -1,6 +1,7 @@
{ self, inputs, lib, ... }: { { self, inputs, lib, ... }: {
flake.nixosModules.clanCore = { config, pkgs, options, ... }: { flake.nixosModules.clanCore = { config, pkgs, options, ... }: {
imports = [ imports = [
../clanImports
./secrets ./secrets
./zerotier ./zerotier
./networking.nix ./networking.nix
@@ -34,6 +35,11 @@
internal = true; internal = true;
}; };
}; };
# TODO: factor these out into a separate interface.nix.
# Also think about moving these options out of `system.clan`.
# Maybe we should not re-use the already polluted confg.system namespace
# and instead have a separate top-level namespace like `clanOutputs`, with
# well defined options marked as `internal = true;`.
options.system.clan = lib.mkOption { options.system.clan = lib.mkOption {
type = lib.types.submodule { type = lib.types.submodule {
options = { options = {

View File

@@ -0,0 +1,16 @@
{ lib
, ...
}: {
/*
Declaring imports inside the module system does not trigger an infinite
recursion in this case because buildClan generates the imports from the
settings.json file before calling out to evalModules.
*/
options.clanImports = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
A list of imported module names imported from clan-core.clanModules.<name>
The buildClan function will automatically import these modules for the current machine.
'';
};
}

View File

@@ -45,9 +45,9 @@ def verify_machine_config(
cwd=flake, cwd=flake,
env=env, env=env,
) )
if proc.returncode != 0: if proc.returncode != 0:
return proc.stderr return proc.stderr
return None return None
def config_for_machine(machine_name: str) -> dict: def config_for_machine(machine_name: str) -> dict:
@@ -85,32 +85,47 @@ def set_config_for_machine(machine_name: str, config: dict) -> Optional[str]:
return None return None
def schema_for_machine(machine_name: str, flake: Optional[Path] = None) -> dict: def schema_for_machine(
machine_name: str, config: Optional[dict] = None, flake: Optional[Path] = None
) -> dict:
if flake is None: if flake is None:
flake = get_clan_flake_toplevel() flake = get_clan_flake_toplevel()
# use nix eval to lib.evalModules .#nixosModules.machine-{machine_name} # use nix eval to lib.evalModules .#nixosConfigurations.<machine_name>.options.clan
proc = subprocess.run( with NamedTemporaryFile(mode="w") as clan_machine_settings_file:
nix_eval( env = os.environ.copy()
flags=[ inject_config_flags = []
"--impure", if config is not None:
"--show-trace", json.dump(config, clan_machine_settings_file, indent=2)
"--expr", clan_machine_settings_file.seek(0)
f""" env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
let inject_config_flags = [
flake = builtins.getFlake (toString {flake}); "--impure", # needed to access CLAN_MACHINE_SETTINGS_FILE
lib = import {nixpkgs_source()}/lib; ]
options = flake.nixosConfigurations.{machine_name}.options; proc = subprocess.run(
clanOptions = options.clan; nix_eval(
jsonschemaLib = import {Path(__file__).parent / "jsonschema"} {{ inherit lib; }}; flags=inject_config_flags
jsonschema = jsonschemaLib.parseOptions clanOptions; + [
in "--impure",
jsonschema "--show-trace",
""", "--expr",
], f"""
), let
capture_output=True, flake = builtins.getFlake (toString {flake});
text=True, lib = import {nixpkgs_source()}/lib;
) options = flake.nixosConfigurations.{machine_name}.options;
clanOptions = options.clan;
jsonschemaLib = import {Path(__file__).parent / "jsonschema"} {{ inherit lib; }};
jsonschema = jsonschemaLib.parseOptions clanOptions;
in
jsonschema
""",
],
),
capture_output=True,
text=True,
cwd=flake,
env=env,
)
if proc.returncode != 0: if proc.returncode != 0:
print(proc.stderr, file=sys.stderr) print(proc.stderr, file=sys.stderr)
raise Exception( raise Exception(

View File

@@ -69,6 +69,14 @@ async def get_machine_schema(name: str) -> SchemaResponse:
return SchemaResponse(schema=schema) return SchemaResponse(schema=schema)
@router.put("/api/machines/{name}/schema")
async def set_machine_schema(
name: str, config: Annotated[dict, Body()]
) -> SchemaResponse:
schema = schema_for_machine(name, config)
return SchemaResponse(schema=schema)
@router.get("/api/machines/{name}/verify") @router.get("/api/machines/{name}/verify")
async def put_verify_machine_config(name: str) -> VerifyMachineResponse: async def put_verify_machine_config(name: str) -> VerifyMachineResponse:
error = verify_machine_config(name) error = verify_machine_config(name)

View File

@@ -0,0 +1,11 @@
{ lib
, ...
}: {
options.clan.fake-module.fake-flag = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
A useless fake flag fro testing purposes.
'';
};
}

View File

@@ -2,36 +2,57 @@
# this placeholder is replaced by the path to nixpkgs # this placeholder is replaced by the path to nixpkgs
inputs.nixpkgs.url = "__NIXPKGS__"; inputs.nixpkgs.url = "__NIXPKGS__";
outputs = inputs: { outputs = inputs':
nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem { let
modules = [ # fake clan-core input
./nixosModules/machine1.nix fake-clan-core = {
( clanModules.fake-module = ./fake-module.nix;
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != "" };
then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE")) inputs = inputs' // { clan-core = fake-clan-core; };
else if builtins.pathExists ./machines/machine1/settings.json machineSettings = (
then builtins.fromJSON (builtins.readFile ./machines/machine1/settings.json) if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != ""
else { } then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
) else if builtins.pathExists ./machines/machine1/settings.json
({ lib, options, pkgs, ... }: { then builtins.fromJSON (builtins.readFile ./machines/machine1/settings.json)
config = { else { }
nixpkgs.hostPlatform = "x86_64-linux"; );
# speed up by not instantiating nixpkgs twice and disable documentation machineImports =
nixpkgs.pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux; map
documentation.enable = false; (module: fake-clan-core.clanModules.${module})
}; (machineSettings.clanImports or [ ]);
options.clanCore.optionsNix = lib.mkOption { in
type = lib.types.raw; {
internal = true; nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem {
readOnly = true; modules =
default = (pkgs.nixosOptionsDoc { inherit options; }).optionsNix; machineImports ++ [
defaultText = "optionsNix"; ./nixosModules/machine1.nix
description = '' machineSettings
This is to export nixos options used for `clan config` ({ lib, options, pkgs, ... }: {
''; config = {
}; nixpkgs.hostPlatform = "x86_64-linux";
}) # speed up by not instantiating nixpkgs twice and disable documentation
]; nixpkgs.pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux;
documentation.enable = false;
};
options.clanCore.optionsNix = lib.mkOption {
type = lib.types.raw;
internal = true;
readOnly = true;
default = (pkgs.nixosOptionsDoc { inherit options; }).optionsNix;
defaultText = "optionsNix";
description = ''
This is to export nixos options used for `clan config`
'';
};
options.clanImports = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
A list of imported module names imported from clan-core.clanModules.<name>
The buildClan function will automatically import these modules for the current machine.
'';
};
})
];
};
}; };
};
} }

View File

@@ -68,20 +68,13 @@ def test_configure_machine(api: TestClient, test_flake: Path) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": {}} assert response.json() == {"config": {}}
# set some valid config fs_config = dict(
config2 = dict(
clan=dict(
jitsi=dict(
enable=True,
),
),
fileSystems={ fileSystems={
"/": dict( "/": dict(
device="/dev/fake_disk", device="/dev/fake_disk",
fsType="ext4", fsType="ext4",
), ),
}, },
# set boot.loader.grub.devices
boot=dict( boot=dict(
loader=dict( loader=dict(
grub=dict( grub=dict(
@@ -90,6 +83,16 @@ def test_configure_machine(api: TestClient, test_flake: Path) -> None:
), ),
), ),
) )
# set some valid config
config2 = dict(
clan=dict(
jitsi=dict(
enable=True,
),
),
**fs_config,
)
response = api.put( response = api.put(
"/api/machines/machine1/config", "/api/machines/machine1/config",
json=config2, json=config2,
@@ -116,3 +119,58 @@ def test_configure_machine(api: TestClient, test_flake: Path) -> None:
response = api.get("/api/machines/machine1/verify") response = api.get("/api/machines/machine1/verify")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"success": True, "error": None} assert response.json() == {"success": True, "error": None}
# get the schema with an extra module imported
response = api.put(
"/api/machines/machine1/schema",
json={"clanImports": ["fake-module"]},
)
# expect the result schema to contain the fake-module.fake-flag option
assert response.status_code == 200
assert (
response.json()["schema"]["properties"]["fake-module"]["properties"][
"fake-flag"
]["type"]
== "boolean"
)
# new config importing an extra clanModule (clanModules.fake-module)
config_with_imports: dict = {
"clanImports": ["fake-module"],
"clan": {
"fake-module": {
"fake-flag": True,
},
},
**fs_config,
}
# set the fake-module.fake-flag option to true
response = api.put(
"/api/machines/machine1/config",
json=config_with_imports,
)
assert response.status_code == 200
assert response.json() == {
"config": {
"clanImports": ["fake-module"],
"clan": {
"fake-module": {
"fake-flag": True,
},
},
**fs_config,
}
}
# remove the import from the config
config_with_empty_imports = dict(
clanImports=[],
**fs_config,
)
response = api.put(
"/api/machines/machine1/config",
json=config_with_empty_imports,
)
assert response.status_code == 200
assert response.json() == {"config": config_with_empty_imports}

View File

@@ -4,5 +4,5 @@ from clan_cli.config import machine
def test_schema_for_machine(test_flake: Path) -> None: def test_schema_for_machine(test_flake: Path) -> None:
schema = machine.schema_for_machine("machine1", test_flake) schema = machine.schema_for_machine("machine1", flake=test_flake)
assert "properties" in schema assert "properties" in schema