Merge pull request 'api/machines: better input/output validation' (#472) from DavHau-dave into main
All checks were successful
assets1 / test (push) Successful in 22s
checks / test (push) Successful in 43s
checks-impure / test (push) Successful in 1m24s

This commit is contained in:
clan-bot 2023-11-06 10:59:14 +00:00
commit 231f1fe322
4 changed files with 67 additions and 22 deletions

View File

@ -2,7 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import AnyUrl, BaseModel, validator from pydantic import AnyUrl, BaseModel, Extra, validator
from ..dirs import clan_data_dir, clan_flakes_dir from ..dirs import clan_data_dir, clan_flakes_dir
from ..flakes.create import DEFAULT_URL from ..flakes.create import DEFAULT_URL
@ -29,3 +29,12 @@ class ClanFlakePath(BaseModel):
class FlakeCreateInput(ClanFlakePath): class FlakeCreateInput(ClanFlakePath):
url: AnyUrl = DEFAULT_URL url: AnyUrl = DEFAULT_URL
class MachineConfig(BaseModel):
clanImports: list[str] = [] # noqa: N815
clan: dict = {}
# allow extra fields to cover the full spectrum of a nixos config
class Config:
extra = Extra.allow

View File

@ -1,7 +1,7 @@
from enum import Enum from enum import Enum
from typing import Dict, List from typing import Dict, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Extra, Field
from ..async_cmd import CmdOut from ..async_cmd import CmdOut
from ..task_manager import TaskStatus from ..task_manager import TaskStatus
@ -36,7 +36,12 @@ class MachineResponse(BaseModel):
class ConfigResponse(BaseModel): class ConfigResponse(BaseModel):
config: dict clanImports: list[str] = [] # noqa: N815
clan: dict = {}
# allow extra fields to cover the full spectrum of a nixos config
class Config:
extra = Extra.allow
class SchemaResponse(BaseModel): class SchemaResponse(BaseModel):

View File

@ -4,6 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Body from fastapi import APIRouter, Body
from clan_cli.webui.api_inputs import MachineConfig
from ...config.machine import ( from ...config.machine import (
config_for_machine, config_for_machine,
schema_for_machine, schema_for_machine,
@ -55,15 +57,15 @@ async def get_machine(flake_name: FlakeName, name: str) -> MachineResponse:
@router.get("/api/{flake_name}/machines/{name}/config", tags=[Tags.machine]) @router.get("/api/{flake_name}/machines/{name}/config", tags=[Tags.machine])
async def get_machine_config(flake_name: FlakeName, name: str) -> ConfigResponse: async def get_machine_config(flake_name: FlakeName, name: str) -> ConfigResponse:
config = config_for_machine(flake_name, name) config = config_for_machine(flake_name, name)
return ConfigResponse(config=config) return ConfigResponse(**config)
@router.put("/api/{flake_name}/machines/{name}/config", tags=[Tags.machine]) @router.put("/api/{flake_name}/machines/{name}/config", tags=[Tags.machine])
async def set_machine_config( async def set_machine_config(
flake_name: FlakeName, name: str, config: Annotated[dict, Body()] flake_name: FlakeName, name: str, config: Annotated[MachineConfig, Body()]
) -> ConfigResponse: ) -> None:
set_config_for_machine(flake_name, name, config) conf = dict(config)
return ConfigResponse(config=config) set_config_for_machine(flake_name, name, conf)
@router.get("/api/{flake_name}/machines/{name}/schema", tags=[Tags.machine]) @router.get("/api/{flake_name}/machines/{name}/schema", tags=[Tags.machine])

View File

@ -37,7 +37,10 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
# ensure an empty config is returned by default for a new machine # ensure an empty config is returned by default for a new machine
response = api.get(f"/api/{test_flake.name}/machines/machine1/config") response = api.get(f"/api/{test_flake.name}/machines/machine1/config")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": {}} assert response.json() == {
"clanImports": [],
"clan": {},
}
# get jsonschema for machine # get jsonschema for machine
response = api.get(f"/api/{test_flake.name}/machines/machine1/schema") response = api.get(f"/api/{test_flake.name}/machines/machine1/schema")
@ -75,7 +78,7 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
# ensure the config has actually been updated # ensure the config has actually been updated
response = api.get(f"/api/{test_flake.name}/machines/machine1/config") response = api.get(f"/api/{test_flake.name}/machines/machine1/config")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": invalid_config} assert response.json() == dict(clanImports=[], **invalid_config)
# the part of the config that makes the evaluation pass # the part of the config that makes the evaluation pass
fs_config = dict( fs_config = dict(
@ -109,12 +112,18 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
json=config2, json=config2,
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": config2}
# ensure the config has been applied
response = api.get(
f"/api/{test_flake.name}/machines/machine1/config",
)
assert response.status_code == 200
assert response.json() == dict(clanImports=[], **config2)
# get the config again # get the config again
response = api.get(f"/api/{test_flake.name}/machines/machine1/config") response = api.get(f"/api/{test_flake.name}/machines/machine1/config")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": config2} assert response.json() == {"clanImports": [], **config2}
# ensure PUT on the config is idempotent by passing the config again # ensure PUT on the config is idempotent by passing the config again
# For example, this should not result in the boot.loader.grub.devices being # For example, this should not result in the boot.loader.grub.devices being
@ -124,7 +133,13 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
json=config2, json=config2,
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": config2}
# ensure the config has been applied
response = api.get(
f"/api/{test_flake.name}/machines/machine1/config",
)
assert response.status_code == 200
assert response.json() == dict(clanImports=[], **config2)
# verify the machine config evaluates # verify the machine config evaluates
response = api.get(f"/api/{test_flake.name}/machines/machine1/verify") response = api.get(f"/api/{test_flake.name}/machines/machine1/verify")
@ -163,8 +178,13 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
json=config_with_imports, json=config_with_imports,
) )
assert response.status_code == 200 assert response.status_code == 200
# ensure the config has been applied
response = api.get(
f"/api/{test_flake.name}/machines/machine1/config",
)
assert response.status_code == 200
assert response.json() == { assert response.json() == {
"config": {
"clanImports": ["fake-module"], "clanImports": ["fake-module"],
"clan": { "clan": {
"fake-module": { "fake-module": {
@ -173,7 +193,6 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
}, },
**fs_config, **fs_config,
} }
}
# remove the import from the config # remove the import from the config
config_with_empty_imports = dict( config_with_empty_imports = dict(
@ -185,4 +204,14 @@ def test_configure_machine(api: TestClient, test_flake: FlakeForTest) -> None:
json=config_with_empty_imports, json=config_with_empty_imports,
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"config": config_with_empty_imports}
# ensure the config has been applied
response = api.get(
f"/api/{test_flake.name}/machines/machine1/config",
)
assert response.status_code == 200
assert response.json() == {
"clanImports": ["fake-module"],
"clan": {},
**config_with_empty_imports,
}