api/machines: allow importing extra modules
All checks were successful
checks-impure / test (pull_request) Successful in 36s
checks / test (pull_request) Successful in 2m6s

- 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
, 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} = { ... }
@ -7,6 +7,9 @@ let
machinesDirs = lib.optionalAttrs (builtins.pathExists "${directory}/machines") (builtins.readDir (directory + /machines));
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" != ""
then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
else
@ -14,18 +17,33 @@ 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 [ ]);
# TODO: remove default system once we have a hardware-config mechanism
nixosConfiguration = { system ? "x86_64-linux", name }: nixpkgs.lib.nixosSystem {
modules = [
self.nixosModules.clanCore
(machineSettings name)
(machines.${name} or { })
{
clanCore.machineName = name;
clanCore.clanDir = directory;
nixpkgs.hostPlatform = lib.mkForce system;
}
];
modules =
let
settings = machineSettings name;
in
(machineImports settings)
++ [
settings
clan-core.nixosModules.clanCore
(machines.${name} or { })
{
clanCore.machineName = name;
clanCore.clanDir = directory;
nixpkgs.hostPlatform = lib.mkForce system;
}
];
inherit specialArgs;
};

View File

@ -1,6 +1,6 @@
{ lib, self, nixpkgs, ... }:
{ lib, clan-core, nixpkgs, ... }:
{
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 {
inherit lib;
inherit self;
inherit (inputs) nixpkgs;
clan-core = self;
};
}

View File

@ -1,6 +1,7 @@
{ self, inputs, lib, ... }: {
flake.nixosModules.clanCore = { config, pkgs, options, ... }: {
imports = [
../clanImports
./secrets
./zerotier
./networking.nix
@ -34,6 +35,11 @@
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 {
type = lib.types.submodule {
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,
env=env,
)
if proc.returncode != 0:
return proc.stderr
return None
if proc.returncode != 0:
return proc.stderr
return None
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
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:
flake = get_clan_flake_toplevel()
# use nix eval to lib.evalModules .#nixosModules.machine-{machine_name}
proc = subprocess.run(
nix_eval(
flags=[
"--impure",
"--show-trace",
"--expr",
f"""
let
flake = builtins.getFlake (toString {flake});
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,
)
# use nix eval to lib.evalModules .#nixosConfigurations.<machine_name>.options.clan
with NamedTemporaryFile(mode="w") as clan_machine_settings_file:
env = os.environ.copy()
inject_config_flags = []
if config is not None:
json.dump(config, clan_machine_settings_file, indent=2)
clan_machine_settings_file.seek(0)
env["CLAN_MACHINE_SETTINGS_FILE"] = clan_machine_settings_file.name
inject_config_flags = [
"--impure", # needed to access CLAN_MACHINE_SETTINGS_FILE
]
proc = subprocess.run(
nix_eval(
flags=inject_config_flags
+ [
"--impure",
"--show-trace",
"--expr",
f"""
let
flake = builtins.getFlake (toString {flake});
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:
print(proc.stderr, file=sys.stderr)
raise Exception(

View File

@ -69,6 +69,14 @@ async def get_machine_schema(name: str) -> SchemaResponse:
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")
async def put_verify_machine_config(name: str) -> VerifyMachineResponse:
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
inputs.nixpkgs.url = "__NIXPKGS__";
outputs = inputs: {
nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem {
modules = [
./nixosModules/machine1.nix
(
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != ""
then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
else if builtins.pathExists ./machines/machine1/settings.json
then builtins.fromJSON (builtins.readFile ./machines/machine1/settings.json)
else { }
)
({ 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`
'';
};
})
];
outputs = inputs':
let
# fake clan-core input
fake-clan-core = {
clanModules.fake-module = ./fake-module.nix;
};
inputs = inputs' // { clan-core = fake-clan-core; };
machineSettings = (
if builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE" != ""
then builtins.fromJSON (builtins.readFile (builtins.getEnv "CLAN_MACHINE_SETTINGS_FILE"))
else if builtins.pathExists ./machines/machine1/settings.json
then builtins.fromJSON (builtins.readFile ./machines/machine1/settings.json)
else { }
);
machineImports =
map
(module: fake-clan-core.clanModules.${module})
(machineSettings.clanImports or [ ]);
in
{
nixosConfigurations.machine1 = inputs.nixpkgs.lib.nixosSystem {
modules =
machineImports ++ [
./nixosModules/machine1.nix
machineSettings
({ 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.json() == {"config": {}}
# set some valid config
config2 = dict(
clan=dict(
jitsi=dict(
enable=True,
),
),
fs_config = dict(
fileSystems={
"/": dict(
device="/dev/fake_disk",
fsType="ext4",
),
},
# set boot.loader.grub.devices
boot=dict(
loader=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(
"/api/machines/machine1/config",
json=config2,
@ -116,3 +119,58 @@ def test_configure_machine(api: TestClient, test_flake: Path) -> None:
response = api.get("/api/machines/machine1/verify")
assert response.status_code == 200
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:
schema = machine.schema_for_machine("machine1", test_flake)
schema = machine.schema_for_machine("machine1", flake=test_flake)
assert "properties" in schema