From a29f301f84d631a73882e5edaf6af7b60e773b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Thu, 14 Sep 2023 16:57:19 +0200 Subject: [PATCH] add vms --- nixosModules/clanCore/flake-module.nix | 1 + nixosModules/clanCore/vm.nix | 8 ++ pkgs/clan-cli/clan_cli/webui/app.py | 4 +- pkgs/clan-cli/clan_cli/webui/routers/vms.py | 113 ++++++++++++++++++ pkgs/clan-cli/clan_cli/webui/schemas.py | 13 ++ pkgs/clan-cli/default.nix | 3 +- .../tests/test_flake_with_core/.clan-flake | 0 .../tests/test_flake_with_core/flake.nix | 15 +++ pkgs/clan-cli/tests/test_vms_api.py | 33 +++++ 9 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 nixosModules/clanCore/vm.nix create mode 100644 pkgs/clan-cli/clan_cli/webui/routers/vms.py create mode 100644 pkgs/clan-cli/tests/test_flake_with_core/.clan-flake create mode 100644 pkgs/clan-cli/tests/test_flake_with_core/flake.nix create mode 100644 pkgs/clan-cli/tests/test_vms_api.py diff --git a/nixosModules/clanCore/flake-module.nix b/nixosModules/clanCore/flake-module.nix index 868a5237..0fcf4cd3 100644 --- a/nixosModules/clanCore/flake-module.nix +++ b/nixosModules/clanCore/flake-module.nix @@ -7,6 +7,7 @@ inputs.sops-nix.nixosModules.sops # just some example options. Can be removed later ./bloatware + ./vm.nix ]; options.clanSchema = lib.mkOption { type = lib.types.attrs; diff --git a/nixosModules/clanCore/vm.nix b/nixosModules/clanCore/vm.nix new file mode 100644 index 00000000..4382c8ca --- /dev/null +++ b/nixosModules/clanCore/vm.nix @@ -0,0 +1,8 @@ +{ config, options, lib, ... }: { + system.clan.vm.config = { + enabled = options.virtualisation ? cores; + } // (lib.optionalAttrs (options.virtualisation ? cores) { + inherit (config.virtualisation) cores graphics; + memory_size = config.virtualisation.memorySize; + }); +} diff --git a/pkgs/clan-cli/clan_cli/webui/app.py b/pkgs/clan-cli/clan_cli/webui/app.py index e8396549..76f5809f 100644 --- a/pkgs/clan-cli/clan_cli/webui/app.py +++ b/pkgs/clan-cli/clan_cli/webui/app.py @@ -3,7 +3,7 @@ from fastapi.routing import APIRoute from fastapi.staticfiles import StaticFiles from .assets import asset_path -from .routers import health, machines, root +from .routers import health, machines, root, vms def setup_app() -> FastAPI: @@ -11,6 +11,8 @@ def setup_app() -> FastAPI: app.include_router(health.router) app.include_router(machines.router) app.include_router(root.router) + app.include_router(vms.router) + app.add_exception_handler(vms.NixBuildException, vms.nix_build_exception_handler) app.mount("/static", StaticFiles(directory=asset_path()), name="static") diff --git a/pkgs/clan-cli/clan_cli/webui/routers/vms.py b/pkgs/clan-cli/clan_cli/webui/routers/vms.py new file mode 100644 index 00000000..18c8c74b --- /dev/null +++ b/pkgs/clan-cli/clan_cli/webui/routers/vms.py @@ -0,0 +1,113 @@ +import asyncio +import json +import shlex +from typing import Annotated, AsyncIterator + +from fastapi import APIRouter, Body, HTTPException, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse, StreamingResponse + +from ...nix import nix_build, nix_eval +from ..schemas import VmConfig, VmInspectResponse + +router = APIRouter() + + +class NixBuildException(HTTPException): + def __init__(self, msg: str, loc: list = ["body", "flake_attr"]): + detail = [ + { + "loc": loc, + "msg": msg, + "type": "value_error", + } + ] + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail + ) + + +def nix_build_exception_handler( + request: Request, exc: NixBuildException +) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=jsonable_encoder(dict(detail=exc.detail)), + ) + + +def nix_inspect_vm(machine: str, flake_url: str) -> list[str]: + return nix_eval( + [ + f"{flake_url}#nixosConfigurations.{json.dumps(machine)}.config.system.clan.vm.config" + ] + ) + + +def nix_build_vm(machine: str, flake_url: str) -> list[str]: + return nix_build( + [ + f"{flake_url}#nixosConfigurations.{json.dumps(machine)}.config.system.build.vm" + ] + ) + + +@router.post("/api/vms/inspect") +async def inspect_vm( + flake_url: Annotated[str, Body()], flake_attr: Annotated[str, Body()] +) -> VmInspectResponse: + cmd = nix_inspect_vm(flake_attr, flake_url=flake_url) + proc = await asyncio.create_subprocess_exec( + cmd[0], + *cmd[1:], + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + raise NixBuildException( + f""" +Failed to evaluate vm from '{flake_url}#{flake_attr}'. +command: {shlex.join(cmd)} +exit code: {proc.returncode} +command output: +{stderr.decode("utf-8")} +""" + ) + data = json.loads(stdout) + return VmInspectResponse( + config=VmConfig(flake_url=flake_url, flake_attr=flake_attr, **data) + ) + + +async def vm_build(vm: VmConfig) -> AsyncIterator[str]: + cmd = nix_build_vm(vm.flake_attr, flake_url=vm.flake_url) + proc = await asyncio.create_subprocess_exec( + cmd[0], + *cmd[1:], + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None and proc.stderr is not None + async for line in proc.stdout: + yield line.decode("utf-8", "ignore") + stderr = "" + async for line in proc.stderr: + stderr += line.decode("utf-8", "ignore") + res = await proc.wait() + if res != 0: + raise NixBuildException( + f""" +Failed to build vm from '{vm.flake_url}#{vm.flake_attr}'. +command: {shlex.join(cmd)} +exit code: {res} +command output: +{stderr} + """ + ) + + +@router.post("/api/vms/create") +async def create_vm(vm: Annotated[VmConfig, Body()]) -> StreamingResponse: + return StreamingResponse(vm_build(vm)) diff --git a/pkgs/clan-cli/clan_cli/webui/schemas.py b/pkgs/clan-cli/clan_cli/webui/schemas.py index 90c5437a..dc6ea3f5 100644 --- a/pkgs/clan-cli/clan_cli/webui/schemas.py +++ b/pkgs/clan-cli/clan_cli/webui/schemas.py @@ -32,3 +32,16 @@ class ConfigResponse(BaseModel): class SchemaResponse(BaseModel): schema_: dict = Field(alias="schema") + + +class VmConfig(BaseModel): + flake_url: str + flake_attr: str + + cores: int + memory_size: int + graphics: bool + + +class VmInspectResponse(BaseModel): + config: VmConfig diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index c73810b2..1f43eb45 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -98,7 +98,8 @@ python3.pkgs.buildPythonPackage { chmod +w -R ./src cd ./src - NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 ${checkPython}/bin/python -m pytest -s ./tests + export NIX_STATE_DIR=$TMPDIR/nix IN_NIX_SANDBOX=1 + ${checkPython}/bin/python -m pytest -m "not impure" -s ./tests touch $out ''; passthru.clan-openapi = runCommand "clan-openapi" { } '' diff --git a/pkgs/clan-cli/tests/test_flake_with_core/.clan-flake b/pkgs/clan-cli/tests/test_flake_with_core/.clan-flake new file mode 100644 index 00000000..e69de29b diff --git a/pkgs/clan-cli/tests/test_flake_with_core/flake.nix b/pkgs/clan-cli/tests/test_flake_with_core/flake.nix new file mode 100644 index 00000000..0a36a133 --- /dev/null +++ b/pkgs/clan-cli/tests/test_flake_with_core/flake.nix @@ -0,0 +1,15 @@ +{ + # this placeholder is replaced by the path to nixpkgs + inputs.clan-core.url = "__CLAN_CORE__"; + + outputs = { self, clan-core }: { + nixosConfigurations = clan-core.lib.buildClan { + directory = self; + machines = { + vm1 = { modulesPath, ... }: { + imports = [ "${toString modulesPath}/virtualisation/qemu-vm.nix" ]; + }; + }; + }; + }; +} diff --git a/pkgs/clan-cli/tests/test_vms_api.py b/pkgs/clan-cli/tests/test_vms_api.py new file mode 100644 index 00000000..8935e6cc --- /dev/null +++ b/pkgs/clan-cli/tests/test_vms_api.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest +from api import TestClient + + +@pytest.mark.impure +def test_inspect(api: TestClient, test_flake_with_core: Path) -> None: + response = api.post( + "/api/vms/inspect", + json=dict(flake_url=str(test_flake_with_core), flake_attr="vm1"), + ) + assert response.status_code == 200, "Failed to inspect vm" + config = response.json()["config"] + assert config.get("flake_attr") == "vm1" + assert config.get("cores") == 1 + assert config.get("memory_size") == 1024 + assert config.get("graphics") is True + + +@pytest.mark.impure +def test_create(api: TestClient, test_flake_with_core: Path) -> None: + response = api.post( + "/api/vms/create", + json=dict( + flake_url=str(test_flake_with_core), + flake_attr="vm1", + cores=1, + memory_size=1024, + graphics=True, + ), + ) + assert response.status_code == 200, "Failed to inspect vm"