diff --git a/pkgs/clan-cli/clan_cli/clan_uri.py b/pkgs/clan-cli/clan_cli/clan_uri.py index 51b05cd0..d4c1be40 100644 --- a/pkgs/clan-cli/clan_cli/clan_uri.py +++ b/pkgs/clan-cli/clan_cli/clan_uri.py @@ -106,6 +106,10 @@ class ClanURI: @classmethod def from_str(cls, url: str, params: ClanParameters | None = None) -> Self: # noqa + prefix = "clan://" + if url.startswith(prefix): + url = url[len(prefix) :] + if params is None: return cls(f"clan://{url}") @@ -118,4 +122,4 @@ class ClanURI: return cls(f"clan://{new_url}") def __str__(self) -> str: - return f"ClanURI({self._components.geturl()})" + return self.get_full_uri() diff --git a/pkgs/clan-cli/clan_cli/flakes/inspect.py b/pkgs/clan-cli/clan_cli/flakes/inspect.py index 42f5ec91..cfdd0362 100644 --- a/pkgs/clan-cli/clan_cli/flakes/inspect.py +++ b/pkgs/clan-cli/clan_cli/flakes/inspect.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from pathlib import Path from ..errors import ClanError +from ..machines.list import list_machines from ..nix import nix_config, nix_eval, nix_metadata @@ -24,6 +25,12 @@ def inspect_flake(flake_url: str | Path, flake_attr: str) -> FlakeConfig: config = nix_config() system = config["system"] + machines = list_machines(flake_url) + if flake_attr not in machines: + raise ClanError( + f"Machine {flake_attr} not found in {flake_url}. Available machines: {', '.join(machines)}" + ) + cmd = nix_eval( [ f'{flake_url}#clanInternals.machines."{system}"."{flake_attr}".config.clanCore.clanIcon' diff --git a/pkgs/clan-cli/clan_cli/history/add.py b/pkgs/clan-cli/clan_cli/history/add.py index 2e98406b..d8253b67 100644 --- a/pkgs/clan-cli/clan_cli/history/add.py +++ b/pkgs/clan-cli/clan_cli/history/add.py @@ -77,12 +77,12 @@ def add_history(uri: ClanURI) -> list[HistoryEntry]: def add_history_command(args: argparse.Namespace) -> None: - add_history(args.path) + add_history(args.uri) # takes a (sub)parser and configures it def register_add_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument( - "uri", type=ClanURI, help="Path to the flake", default=ClanURI(".") + "uri", type=ClanURI.from_str, help="Path to the flake", default="." ) parser.set_defaults(func=add_history_command) diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index 8ee271a7..b16a3c64 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -11,12 +11,12 @@ from ..nix import nix_config, nix_eval log = logging.getLogger(__name__) -def list_machines(flake_dir: Path) -> list[str]: +def list_machines(flake_url: Path | str) -> list[str]: config = nix_config() system = config["system"] cmd = nix_eval( [ - f"{flake_dir}#clanInternals.machines.{system}", + f"{flake_url}#clanInternals.machines.{system}", "--apply", "builtins.attrNames", "--json", diff --git a/pkgs/clan-cli/tests/test_clan_uri.py b/pkgs/clan-cli/tests/test_clan_uri.py index db520cc8..8a0b9d04 100644 --- a/pkgs/clan-cli/tests/test_clan_uri.py +++ b/pkgs/clan-cli/tests/test_clan_uri.py @@ -125,6 +125,11 @@ def test_from_str() -> None: assert uri.params.flake_attr == "defaultVM" assert uri.get_internal() == "~/Downloads/democlan" + uri_str = "clan://~/Downloads/democlan" + uri = ClanURI.from_str(url=uri_str) + assert uri.params.flake_attr == "defaultVM" + assert uri.get_internal() == "~/Downloads/democlan" + def test_remote_with_all_params() -> None: # Create a ClanURI object from a remote URI with parameters diff --git a/pkgs/clan-cli/tests/test_history_cli.py b/pkgs/clan-cli/tests/test_history_cli.py index 604fbca4..9acc7401 100644 --- a/pkgs/clan-cli/tests/test_history_cli.py +++ b/pkgs/clan-cli/tests/test_history_cli.py @@ -1,10 +1,12 @@ import json from typing import TYPE_CHECKING +import pytest from cli import Cli from fixtures_flakes import FlakeForTest from pytest import CaptureFixture +from clan_cli.clan_uri import ClanParameters, ClanURI from clan_cli.dirs import user_history_file from clan_cli.history.add import HistoryEntry @@ -12,14 +14,17 @@ if TYPE_CHECKING: pass +@pytest.mark.impure def test_history_add( test_flake_with_core: FlakeForTest, ) -> None: cli = Cli() + params = ClanParameters(flake_attr="vm1") + uri = ClanURI.from_path(test_flake_with_core.path, params=params) cmd = [ "history", "add", - str(test_flake_with_core.path), + str(uri), ] cli.run(cmd) @@ -30,11 +35,14 @@ def test_history_add( assert history[0].flake.flake_url == str(test_flake_with_core.path) +@pytest.mark.impure def test_history_list( capsys: CaptureFixture, test_flake_with_core: FlakeForTest, ) -> None: cli = Cli() + params = ClanParameters(flake_attr="vm1") + uri = ClanURI.from_path(test_flake_with_core.path, params=params) cmd = [ "history", "list", @@ -43,6 +51,6 @@ def test_history_list( cli.run(cmd) assert str(test_flake_with_core.path) not in capsys.readouterr().out - cli.run(["history", "add", str(test_flake_with_core.path)]) + cli.run(["history", "add", str(uri)]) cli.run(cmd) assert str(test_flake_with_core.path) in capsys.readouterr().out diff --git a/pkgs/clan-cli/tests/test_machines_api.py b/pkgs/clan-cli/tests/test_machines_api.py deleted file mode 100644 index f11ceedd..00000000 --- a/pkgs/clan-cli/tests/test_machines_api.py +++ /dev/null @@ -1,279 +0,0 @@ -import pytest -from api import TestClient -from fixtures_flakes import FlakeForTest - - -def test_machines(api: TestClient, test_flake: FlakeForTest) -> None: - response = api.get(f"/api/machines?flake_dir={test_flake.path}") - assert response.status_code == 200 - assert response.json() == {"machines": []} - response = api.put( - f"/api/machines/test/config?flake_dir={test_flake.path}", json={} - ) - assert response.status_code == 200 - - response = api.get(f"/api/machines/test?flake_dir={test_flake.path}") - assert response.status_code == 200 - assert response.json() == {"machine": {"name": "test", "status": "unknown"}} - - response = api.get(f"/api/machines?flake_dir={test_flake.path}") - assert response.status_code == 200 - assert response.json() == {"machines": [{"name": "test", "status": "unknown"}]} - - -@pytest.mark.with_core -def test_schema_errors(api: TestClient, test_flake_with_core: FlakeForTest) -> None: - # make sure that eval errors do not raise an internal server error - response = api.put( - f"/api/schema?flake_dir={test_flake_with_core.path}", - json={"imports": ["some-invalid-import"]}, - ) - assert response.status_code == 422 - assert ( - "error: string 'some-invalid-import' doesn't represent an absolute path" - in response.json()["detail"][0]["msg"] - ) - - -@pytest.mark.with_core -def test_schema_invalid_clan_imports( - api: TestClient, test_flake_with_core: FlakeForTest -) -> None: - response = api.put( - f"/api/schema?flake_dir={test_flake_with_core.path}", - json={"clanImports": ["non-existing-clan-module"]}, - ) - assert response.status_code == 400 - assert ( - "Some requested clan modules could not be found" - in response.json()["detail"]["msg"] - ) - - -def test_create_machine_invalid_hostname( - api: TestClient, test_flake: FlakeForTest -) -> None: - response = api.put( - f"/api/machines/-invalid-hostname/config?flake_dir={test_flake.path}", - json=dict(), - ) - assert response.status_code == 422 - assert ( - "Machine name must be a valid hostname" in response.json()["detail"][0]["msg"] - ) - - -@pytest.mark.with_core -def test_verify_config_without_machine( - api: TestClient, test_flake_with_core: FlakeForTest -) -> None: - response = api.put( - f"/api/machines/test/verify?flake_dir={test_flake_with_core.path}", - json=dict(), - ) - assert response.status_code == 200 - assert response.json() == {"success": True, "error": None} - - -@pytest.mark.with_core -def test_ensure_empty_config_is_valid( - api: TestClient, test_flake_with_core: FlakeForTest -) -> None: - response = api.put( - f"/api/machines/test/config?flake_dir={test_flake_with_core.path}", - json=dict(), - ) - assert response.status_code == 200 - - response = api.get( - f"/api/machines/test/verify?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 200 - assert response.json() == {"success": True, "error": None} - - -@pytest.mark.with_core -def test_configure_machine(api: TestClient, test_flake_with_core: FlakeForTest) -> None: - # ensure error 404 if machine does not exist when accessing the config - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 404 - - # create the machine - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", json={} - ) - assert response.status_code == 200 - - # ensure an empty config is returned by default for a new machine - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 200 - assert response.json() == { - "clanImports": [], - "clan": {}, - } - - # get jsonschema for without imports - response = api.put( - f"/api/schema?flake_dir={test_flake_with_core.path}", - json={"clanImports": []}, - ) - assert response.status_code == 200 - json_response = response.json() - assert "schema" in json_response and "properties" in json_response["schema"] - - # an invalid config setting some non-existent option - invalid_config = dict( - clan=dict(), - foo=dict( - bar=True, - ), - services=dict( - nginx=dict( - enable=True, - ), - ), - ) - - # verify an invalid config (foo option does not exist) - response = api.put( - f"/api/machines/machine1/verify?flake_dir={test_flake_with_core.path}", - json=invalid_config, - ) - assert response.status_code == 200 - assert "error: The option `foo' does not exist" in response.json()["error"] - - # set come invalid config (foo option does not exist) - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - json=invalid_config, - ) - assert response.status_code == 200 - - # ensure the config has actually been updated - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 200 - assert response.json() == dict(clanImports=[], **invalid_config) - - # set some valid config - config2 = dict( - clan=dict(), - services=dict( - nginx=dict( - enable=True, - ), - ), - ) - - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - json=config2, - ) - assert response.status_code == 200 - - # ensure the config has been applied - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - ) - assert response.status_code == 200 - assert response.json() == dict(clanImports=[], **config2) - - # get the config again - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 200 - assert response.json() == {"clanImports": [], **config2} - - # 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 - # set twice (eg. merged) - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - json=config2, - ) - assert response.status_code == 200 - - # ensure the config has been applied - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - ) - assert response.status_code == 200 - assert response.json() == dict(clanImports=[], **config2) - - # verify the machine config evaluates - response = api.get( - f"/api/machines/machine1/verify?flake_dir={test_flake_with_core.path}" - ) - assert response.status_code == 200 - - assert response.json() == {"success": True, "error": None} - - # get the schema with an extra module imported - response = api.put( - f"/api/schema?flake_dir={test_flake_with_core.path}", - json={"clanImports": ["diskLayouts"]}, - ) - # expect the result schema to contain the deltachat option - assert response.status_code == 200 - assert ( - response.json()["schema"]["properties"]["diskLayouts"]["properties"][ - "singleDiskExt4" - ]["properties"]["device"]["type"] - == "string" - ) - - # new config importing an extra clanModule (clanModules.fake-module) - config_with_imports: dict = { - "clanImports": ["fake-module"], - "clan": { - "fake-module": { - "fake-flag": True, - }, - }, - } - - # set the fake-module.fake-flag option to true - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - json=config_with_imports, - ) - assert response.status_code == 200 - - # ensure the config has been applied - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - ) - assert response.status_code == 200 - assert response.json() == { - "clanImports": ["fake-module"], - "clan": { - "fake-module": { - "fake-flag": True, - }, - }, - } - - # remove the import from the config - response = api.put( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - json=dict( - clanImports=[], - ), - ) - assert response.status_code == 200 - - # ensure the config has been applied - response = api.get( - f"/api/machines/machine1/config?flake_dir={test_flake_with_core.path}", - ) - assert response.status_code == 200 - assert response.json() == { - "clan": {}, - "clanImports": [], - } diff --git a/pkgs/clan-cli/tests/test_machines_cli.py b/pkgs/clan-cli/tests/test_machines_cli.py index 8ee515bd..2b0832e7 100644 --- a/pkgs/clan-cli/tests/test_machines_cli.py +++ b/pkgs/clan-cli/tests/test_machines_cli.py @@ -3,20 +3,25 @@ from cli import Cli from fixtures_flakes import FlakeForTest +@pytest.mark.impure def test_machine_subcommands( - test_flake: FlakeForTest, capsys: pytest.CaptureFixture + test_flake_with_core: FlakeForTest, capsys: pytest.CaptureFixture ) -> None: cli = Cli() - cli.run(["--flake", str(test_flake.path), "machines", "create", "machine1"]) + cli.run( + ["--flake", str(test_flake_with_core.path), "machines", "create", "machine1"] + ) capsys.readouterr() - cli.run(["--flake", str(test_flake.path), "machines", "list"]) + cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) out = capsys.readouterr() - assert "machine1\n" == out.out + assert "machine1\nvm1\nvm2\n" == out.out - cli.run(["--flake", str(test_flake.path), "machines", "delete", "machine1"]) + cli.run( + ["--flake", str(test_flake_with_core.path), "machines", "delete", "machine1"] + ) capsys.readouterr() - cli.run(["--flake", str(test_flake.path), "machines", "list"]) + cli.run(["--flake", str(test_flake_with_core.path), "machines", "list"]) out = capsys.readouterr() - assert "" == out.out + assert "vm1\nvm2\n" == out.out diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models.py b/pkgs/clan-vm-manager/clan_vm_manager/models.py index 15c11654..0f232070 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any import gi -from clan_cli import flakes, vms +from clan_cli import flakes, history, vms gi.require_version("GdkPixbuf", "2.0") from gi.repository import GdkPixbuf @@ -77,7 +77,7 @@ def get_initial_vms(start: int = 0, end: int | None = None) -> list[VM]: # TODO: list_history() should return a list of dicts, not a list of paths # Execute `clan flakes add ` to democlan for this to work - for entry in flakes.history.list_history(): + for entry in history.list.list_history(): flake_config = flakes.inspect.inspect_flake(entry.path, "defaultVM") vm_config = vms.inspect.inspect_vm(entry.path, "defaultVM")