From 888b9bec5c32d93e22e7bc859f2eec2611ea4e80 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:09:25 +0200 Subject: [PATCH 01/24] chore: scaffold uv project structure --- pyproject.toml | 30 ++++++++++++++++++++++++++++++ src/multipass/__init__.py | 1 + tests/conftest.py | 7 +++++++ tests/integration/__init__.py | 0 tests/unit/__init__.py | 0 5 files changed, 38 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/multipass/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/__init__.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3cfcba1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "multipass-sdk" +version = "0.3.0" +description = "Unofficial Python SDK for Canonical Multipass" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +dependencies = [ + "haikunator>=2.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/multipass"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: marks tests as integration tests (require Multipass installed)", +] +addopts = "-m 'not integration'" diff --git a/src/multipass/__init__.py b/src/multipass/__init__.py new file mode 100644 index 0000000..9361f58 --- /dev/null +++ b/src/multipass/__init__.py @@ -0,0 +1 @@ +# populated in Task 9 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6f8f818 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import pytest + + +def pytest_configure(config): + config.addinivalue_line( + "markers", "integration: marks tests as integration tests (require Multipass installed)" + ) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 From 43d4752415cb537a85b89d307c2bffd5a0f519e0 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:10:01 +0200 Subject: [PATCH 02/24] feat: add typed exception hierarchy --- src/multipass/exceptions.py | 44 +++++++++++++++++++++++++++++++++++ tests/unit/test_exceptions.py | 31 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/multipass/exceptions.py create mode 100644 tests/unit/test_exceptions.py diff --git a/src/multipass/exceptions.py b/src/multipass/exceptions.py new file mode 100644 index 0000000..44c512a --- /dev/null +++ b/src/multipass/exceptions.py @@ -0,0 +1,44 @@ +class MultipassError(Exception): + """Base exception for all multipass-sdk errors.""" + + +class MultipassCommandError(MultipassError): + def __init__(self, args: list[str], returncode: int, stdout: str, stderr: str): + self.args_list = args + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + super().__init__( + f"Command {args} failed with exit code {returncode}: {stderr or stdout}" + ) + + +class MultipassNotInstalledError(MultipassError): + def __init__(self) -> None: + super().__init__( + "Multipass binary not found. Install from https://multipass.run" + ) + + +class VmNotFoundError(MultipassError): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"VM '{name}' not found") + + +class VmAlreadyRunningError(MultipassError): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"VM '{name}' is already running") + + +class VmNotRunningError(MultipassError): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"VM '{name}' is not running") + + +class VmAlreadySuspendedError(MultipassError): + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"VM '{name}' is already suspended") diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..5b3accd --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,31 @@ +from multipass.exceptions import ( + MultipassError, + MultipassCommandError, + MultipassNotInstalledError, + VmNotFoundError, + VmAlreadyRunningError, + VmNotRunningError, + VmAlreadySuspendedError, +) + + +def test_all_exceptions_are_subclasses_of_multipass_error(): + assert issubclass(MultipassCommandError, MultipassError) + assert issubclass(MultipassNotInstalledError, MultipassError) + assert issubclass(VmNotFoundError, MultipassError) + assert issubclass(VmAlreadyRunningError, MultipassError) + assert issubclass(VmNotRunningError, MultipassError) + assert issubclass(VmAlreadySuspendedError, MultipassError) + + +def test_multipass_command_error_stores_fields(): + err = MultipassCommandError(["multipass", "info"], 1, "", "instance not found") + assert err.returncode == 1 + assert err.stderr == "instance not found" + assert "multipass" in str(err) + + +def test_vm_not_found_error_includes_name(): + err = VmNotFoundError("my-vm") + assert err.name == "my-vm" + assert "my-vm" in str(err) From 94b71a9c56ef4a08be6e2502e4dbc5b3174d57ff Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:10:46 +0200 Subject: [PATCH 03/24] feat: add CommandBackend protocol, SubprocessBackend, FakeBackend --- src/multipass/_backend.py | 74 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_backend.py | 52 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/multipass/_backend.py create mode 100644 tests/unit/test_backend.py diff --git a/src/multipass/_backend.py b/src/multipass/_backend.py new file mode 100644 index 0000000..7078a5a --- /dev/null +++ b/src/multipass/_backend.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass +from typing import Protocol + +from .exceptions import MultipassNotInstalledError + + +@dataclass +class CommandResult: + args: list[str] + returncode: int + stdout: str + stderr: str + + @property + def success(self) -> bool: + return self.returncode == 0 + + +class CommandBackend(Protocol): + def run(self, args: list[str]) -> CommandResult: ... + + +class SubprocessBackend: + """Real backend — invokes the Multipass CLI via subprocess.""" + + def run(self, args: list[str]) -> CommandResult: + binary = args[0] if args else "multipass" + if not shutil.which(binary): + raise MultipassNotInstalledError() + try: + proc = subprocess.run(args, capture_output=True, text=True) + except FileNotFoundError: + raise MultipassNotInstalledError() + return CommandResult( + args=args, + returncode=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + ) + + +class FakeBackend: + """Test backend — returns pre-configured responses and records all calls.""" + + def __init__( + self, + responses: dict[tuple[str, ...], CommandResult] | None = None, + ) -> None: + self._responses: dict[tuple[str, ...], CommandResult] = responses or {} + self._calls: list[list[str]] = [] + self._default: CommandResult | None = None + + def set_default(self, result: CommandResult) -> None: + self._default = result + + def run(self, args: list[str]) -> CommandResult: + self._calls.append(list(args)) + key = tuple(args) + if key in self._responses: + return self._responses[key] + if self._default is not None: + return self._default + raise KeyError(f"FakeBackend: no response configured for {args!r}") + + @property + def calls(self) -> list[list[str]]: + return list(self._calls) + + def last_call(self) -> list[str]: + return self._calls[-1] if self._calls else [] diff --git a/tests/unit/test_backend.py b/tests/unit/test_backend.py new file mode 100644 index 0000000..4dc5485 --- /dev/null +++ b/tests/unit/test_backend.py @@ -0,0 +1,52 @@ +import pytest +from multipass._backend import CommandResult, FakeBackend + + +def test_command_result_success(): + result = CommandResult(args=["multipass", "list"], returncode=0, stdout="ok", stderr="") + assert result.success is True + + +def test_command_result_failure(): + result = CommandResult(args=["multipass", "info"], returncode=1, stdout="", stderr="not found") + assert result.success is False + + +def test_fake_backend_records_calls(): + backend = FakeBackend() + ok = CommandResult(args=[], returncode=0, stdout="", stderr="") + backend.set_default(ok) + backend.run(["multipass", "list"]) + backend.run(["multipass", "info", "my-vm"]) + assert backend.calls == [ + ["multipass", "list"], + ["multipass", "info", "my-vm"], + ] + + +def test_fake_backend_returns_configured_response(): + expected = CommandResult( + args=["multipass", "list", "--format", "json"], + returncode=0, + stdout='{"list":[]}', + stderr="", + ) + backend = FakeBackend( + responses={("multipass", "list", "--format", "json"): expected} + ) + result = backend.run(["multipass", "list", "--format", "json"]) + assert result.stdout == '{"list":[]}' + + +def test_fake_backend_raises_on_unconfigured_call(): + backend = FakeBackend() + with pytest.raises(KeyError): + backend.run(["multipass", "unknown"]) + + +def test_fake_backend_last_call(): + backend = FakeBackend() + ok = CommandResult(args=[], returncode=0, stdout="", stderr="") + backend.set_default(ok) + backend.run(["multipass", "start", "vm1"]) + assert backend.last_call() == ["multipass", "start", "vm1"] From 62fbd3dfdaf7ffd8c2ed5f9cd5f963cfa89fed8b Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:11:32 +0200 Subject: [PATCH 04/24] feat: add typed data models with JSON parsers --- src/multipass/models.py | 170 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_models.py | 112 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 src/multipass/models.py create mode 100644 tests/unit/test_models.py diff --git a/src/multipass/models.py b/src/multipass/models.py new file mode 100644 index 0000000..2407a45 --- /dev/null +++ b/src/multipass/models.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class VmState(Enum): + RUNNING = "Running" + STOPPED = "Stopped" + DELETED = "Deleted" + SUSPENDED = "Suspended" + STARTING = "Starting" + RESTARTING = "Restarting" + UNKNOWN = "Unknown" + + @classmethod + def _missing_(cls, value: object) -> "VmState": + return cls.UNKNOWN + + +@dataclass +class VmInfo: + name: str + state: VmState + ipv4: list[str] + image: str + image_hash: str + cpus: int + memory_total: str + memory_used: str + disk_total: str + disk_used: str + mounts: dict[str, str] + + @classmethod + def from_info_json(cls, data: dict, name: str) -> "VmInfo": + vm = data["info"][name] + disks = vm.get("disks", {}) + first_disk = next(iter(disks.values()), {}) + memory = vm.get("memory", {}) + return cls( + name=name, + state=VmState(vm.get("state", "Unknown")), + ipv4=vm.get("ipv4", []), + image=vm.get("image_release", ""), + image_hash=vm.get("image_hash", ""), + cpus=int(vm.get("cpu_count", 1)), + memory_total=str(memory.get("total", 0)), + memory_used=str(memory.get("used", 0)), + disk_total=str(first_disk.get("total", "0")), + disk_used=str(first_disk.get("used", "0")), + mounts={ + target: mount_data.get("source_path", "") + for target, mount_data in vm.get("mounts", {}).items() + }, + ) + + @classmethod + def from_list_json(cls, data: dict) -> list["VmInfo"]: + return [ + cls( + name=item["name"], + state=VmState(item.get("state", "Unknown")), + ipv4=item.get("ipv4", []), + image=item.get("release", ""), + image_hash="", + cpus=0, + memory_total="", + memory_used="", + disk_total="", + disk_used="", + mounts={}, + ) + for item in data.get("list", []) + ] + + +@dataclass +class ImageInfo: + aliases: list[str] + os: str + release: str + remote: str + version: str + + @classmethod + def from_find_json(cls, data: dict) -> list["ImageInfo"]: + return [ + cls( + aliases=img.get("aliases", []), + os=img.get("os", ""), + release=img.get("release", ""), + remote=img.get("remote", ""), + version=img.get("version", ""), + ) + for img in data.get("images", {}).values() + ] + + +@dataclass +class NetworkInfo: + name: str + type: str + description: str + + @classmethod + def from_networks_json(cls, data: dict) -> list["NetworkInfo"]: + return [ + cls( + name=item["name"], + type=item.get("type", ""), + description=item.get("description", ""), + ) + for item in data.get("list", []) + ] + + +@dataclass +class VersionInfo: + multipass: str + multipassd: str + + @classmethod + def from_json(cls, data: dict) -> "VersionInfo": + return cls( + multipass=data.get("multipass", ""), + multipassd=data.get("multipassd", ""), + ) + + +@dataclass +class AliasInfo: + alias: str + instance: str + command: str + working_directory: str + + @classmethod + def from_aliases_json(cls, data: dict) -> list["AliasInfo"]: + return [ + cls( + alias=item["alias"], + instance=item.get("instance", ""), + command=item.get("command", ""), + working_directory=item.get("working-directory", ""), + ) + for item in data.get("aliases", []) + ] + + +@dataclass +class SnapshotInfo: + name: str + comment: str + created: str + parent: str | None + instance: str + + @classmethod + def from_snapshots_json(cls, data: dict) -> list["SnapshotInfo"]: + return [ + cls( + name=item["name"], + comment=item.get("comment", ""), + created=item.get("created", ""), + parent=item.get("parent"), + instance=item.get("instance", ""), + ) + for item in data.get("list", []) + ] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..ffff2d4 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,112 @@ +from multipass.models import ( + VmInfo, VmState, ImageInfo, NetworkInfo, VersionInfo, AliasInfo, SnapshotInfo +) + +INFO_JSON = { + "errors": [], + "info": { + "my-vm": { + "cpu_count": "2", + "disks": {"sda1": {"total": "5368709120", "used": "1234567890"}}, + "image_hash": "abc123", + "image_release": "22.04 LTS", + "ipv4": ["192.168.64.2"], + "memory": {"total": 1073741824, "used": 123456789}, + "mounts": {}, + "state": "Running", + } + }, +} + +LIST_JSON = { + "list": [ + {"ipv4": ["192.168.64.2"], "name": "my-vm", "release": "22.04 LTS", "state": "Running"} + ] +} + + +def test_vminfo_from_info_json(): + info = VmInfo.from_info_json(INFO_JSON, "my-vm") + assert info.name == "my-vm" + assert info.state == VmState.RUNNING + assert info.ipv4 == ["192.168.64.2"] + assert info.cpus == 2 + assert info.image == "22.04 LTS" + assert info.image_hash == "abc123" + + +def test_vminfo_from_list_json(): + items = VmInfo.from_list_json(LIST_JSON) + assert len(items) == 1 + assert items[0].name == "my-vm" + assert items[0].state == VmState.RUNNING + assert items[0].ipv4 == ["192.168.64.2"] + + +def test_vmstate_unknown_falls_back(): + info = VmInfo.from_info_json( + {"errors": [], "info": {"x": {**INFO_JSON["info"]["my-vm"], "state": "Weird"}}}, "x" + ) + assert info.state == VmState.UNKNOWN + + +def test_imageinfo_from_json(): + data = { + "errors": [], + "images": { + "22.04": { + "aliases": ["jammy", "lts"], + "os": "Ubuntu", + "release": "22.04 LTS", + "remote": "", + "version": "20230801", + } + }, + } + images = ImageInfo.from_find_json(data) + assert len(images) == 1 + assert "jammy" in images[0].aliases + assert images[0].os == "Ubuntu" + + +def test_version_info_from_json(): + v = VersionInfo.from_json({"multipass": "1.13.0", "multipassd": "1.13.0"}) + assert v.multipass == "1.13.0" + assert v.multipassd == "1.13.0" + + +def test_network_info_from_json(): + data = {"list": [{"description": "Wi-Fi", "name": "en0", "type": "wifi"}]} + nets = NetworkInfo.from_networks_json(data) + assert len(nets) == 1 + assert nets[0].name == "en0" + + +def test_alias_info_from_json(): + data = { + "aliases": [ + {"alias": "myalias", "command": "ls", "instance": "myvm", "working-directory": "default"} + ] + } + aliases = AliasInfo.from_aliases_json(data) + assert len(aliases) == 1 + assert aliases[0].alias == "myalias" + assert aliases[0].instance == "myvm" + + +def test_snapshot_info_from_json(): + data = { + "list": [ + { + "comment": "Before upgrade", + "created": "2023-08-15T10:30:00.000Z", + "instance": "my-vm", + "name": "snapshot1", + "parent": None, + } + ] + } + snaps = SnapshotInfo.from_snapshots_json(data) + assert len(snaps) == 1 + assert snaps[0].name == "snapshot1" + assert snaps[0].parent is None From cad1af5f39f304c0f42d81d3710981a8ba2badb8 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:12:44 +0200 Subject: [PATCH 05/24] feat: add MultipassVM with full method coverage --- src/multipass/vm.py | 147 +++++++++++++++++++++++++ tests/unit/test_vm.py | 248 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 src/multipass/vm.py create mode 100644 tests/unit/test_vm.py diff --git a/src/multipass/vm.py b/src/multipass/vm.py new file mode 100644 index 0000000..5c39739 --- /dev/null +++ b/src/multipass/vm.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json + +from .exceptions import MultipassCommandError, VmNotFoundError +from .models import SnapshotInfo, VmInfo +from ._backend import CommandBackend, CommandResult + +_NOT_FOUND_PHRASES = ("does not exist", "not found", "no such instance") + + +def _raise_for_result(result: CommandResult, vm_name: str) -> None: + if result.success: + return + msg = (result.stderr or result.stdout).lower() + if any(phrase in msg for phrase in _NOT_FOUND_PHRASES): + raise VmNotFoundError(vm_name) + raise MultipassCommandError(result.args, result.returncode, result.stdout, result.stderr) + + +class MultipassVM: + def __init__(self, name: str, cmd: str, backend: CommandBackend) -> None: + self.name = name + self._cmd = cmd + self._backend = backend + + # ------------------------------------------------------------------ info + + def info(self) -> VmInfo: + result = self._backend.run([self._cmd, "info", self.name, "--format", "json"]) + _raise_for_result(result, self.name) + return VmInfo.from_info_json(json.loads(result.stdout), self.name) + + # ------------------------------------------------------------ lifecycle + + def start(self) -> None: + result = self._backend.run([self._cmd, "start", self.name]) + _raise_for_result(result, self.name) + + def stop(self, *, force: bool = False, time: int | None = None) -> None: + cmd = [self._cmd, "stop", self.name] + if force: + cmd.append("--force") + if time is not None: + cmd += ["--time", str(time)] + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + + def restart(self) -> None: + result = self._backend.run([self._cmd, "restart", self.name]) + _raise_for_result(result, self.name) + + def suspend(self) -> None: + result = self._backend.run([self._cmd, "suspend", self.name]) + _raise_for_result(result, self.name) + + def delete(self, *, purge: bool = False) -> None: + cmd = [self._cmd, "delete", self.name] + if purge: + cmd.append("--purge") + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + + def recover(self) -> None: + result = self._backend.run([self._cmd, "recover", self.name]) + _raise_for_result(result, self.name) + + # -------------------------------------------------------------- exec + + def exec(self, command: list[str]) -> CommandResult: + """Execute a command in the VM. command must be a list of args (no shell splitting).""" + cmd = [self._cmd, "exec", self.name, "--"] + command + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + return result + + # ------------------------------------------------------------ transfer + + def transfer(self, source: str, dest: str) -> None: + """Transfer files between host and VM. + + Use 'vm-name:/path' notation for VM paths, plain paths for host. + Always recursive (-r). + """ + result = self._backend.run([self._cmd, "transfer", "-r", source, dest]) + _raise_for_result(result, self.name) + + # -------------------------------------------------------------- mount + + def mount( + self, + source: str, + target: str, + *, + mount_type: str | None = None, + uid_map: str | None = None, + gid_map: str | None = None, + ) -> None: + cmd = [self._cmd, "mount", source, target] + if mount_type: + cmd += ["--type", mount_type] + if uid_map: + cmd += ["--uid-map", uid_map] + if gid_map: + cmd += ["--gid-map", gid_map] + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + + def unmount(self, mount: str) -> None: + result = self._backend.run([self._cmd, "umount", mount]) + _raise_for_result(result, self.name) + + # ---------------------------------------------------------- snapshots + + def snapshots(self) -> list[SnapshotInfo]: + result = self._backend.run( + [self._cmd, "list", "--snapshots", "--format", "json"] + ) + _raise_for_result(result, self.name) + return SnapshotInfo.from_snapshots_json(json.loads(result.stdout)) + + def snapshot(self, name: str, *, comment: str | None = None) -> SnapshotInfo: + cmd = [self._cmd, "snapshot", self.name, "--name", name] + if comment: + cmd += ["--comment", comment] + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + all_snaps = self.snapshots() + for snap in all_snaps: + if snap.name == name: + return snap + raise MultipassCommandError(cmd, 0, "", f"Snapshot '{name}' not found after creation") + + def restore(self, snapshot: str, *, destructive: bool = False) -> None: + cmd = [self._cmd, "restore", f"{self.name}.{snapshot}"] + if destructive: + cmd.append("--destructive") + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + + # --------------------------------------------------------------- clone + + def clone(self, new_name: str) -> "MultipassVM": + cmd = [self._cmd, "clone", self.name, "--name", new_name] + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + return MultipassVM(new_name, self._cmd, self._backend) diff --git a/tests/unit/test_vm.py b/tests/unit/test_vm.py new file mode 100644 index 0000000..4fb43be --- /dev/null +++ b/tests/unit/test_vm.py @@ -0,0 +1,248 @@ +import json +import pytest +from multipass._backend import CommandResult, FakeBackend +from multipass.exceptions import MultipassCommandError, VmNotFoundError +from multipass.models import VmState +from multipass.vm import MultipassVM + +INFO_RESPONSE = json.dumps({ + "errors": [], + "info": { + "my-vm": { + "cpu_count": "2", + "disks": {"sda1": {"total": "5368709120", "used": "1000000000"}}, + "image_hash": "abc123", + "image_release": "22.04 LTS", + "ipv4": ["192.168.64.2"], + "memory": {"total": 1073741824, "used": 123456789}, + "mounts": {}, + "state": "Running", + } + }, +}) + +SNAPSHOTS_JSON = json.dumps({ + "list": [ + { + "comment": "Before upgrade", + "created": "2023-08-15T10:30:00.000Z", + "instance": "my-vm", + "name": "snap1", + "parent": None, + } + ] +}) + + +def make_ok(stdout: str = "") -> CommandResult: + return CommandResult(args=[], returncode=0, stdout=stdout, stderr="") + + +def make_err(stderr: str, returncode: int = 1) -> CommandResult: + return CommandResult(args=[], returncode=returncode, stdout="", stderr=stderr) + + +# ------------------------------------------------------------------ info + +def test_info_returns_vm_info(): + backend = FakeBackend( + {("multipass", "info", "my-vm", "--format", "json"): make_ok(INFO_RESPONSE)} + ) + vm = MultipassVM("my-vm", "multipass", backend) + info = vm.info() + assert info.name == "my-vm" + assert info.state == VmState.RUNNING + assert info.ipv4 == ["192.168.64.2"] + + +def test_info_raises_vm_not_found(): + backend = FakeBackend( + {("multipass", "info", "ghost", "--format", "json"): make_err('instance "ghost" does not exist')} + ) + vm = MultipassVM("ghost", "multipass", backend) + with pytest.raises(VmNotFoundError): + vm.info() + + +def test_info_raises_command_error_on_generic_failure(): + backend = FakeBackend( + {("multipass", "info", "my-vm", "--format", "json"): make_err("some unexpected error")} + ) + vm = MultipassVM("my-vm", "multipass", backend) + with pytest.raises(MultipassCommandError): + vm.info() + + +# ------------------------------------------------------------ lifecycle + +def test_start_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.start() + assert backend.last_call() == ["multipass", "start", "my-vm"] + + +def test_stop_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.stop() + assert backend.last_call() == ["multipass", "stop", "my-vm"] + + +def test_stop_force_adds_flag(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.stop(force=True) + assert "--force" in backend.last_call() + + +def test_delete_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.delete() + assert backend.last_call() == ["multipass", "delete", "my-vm"] + + +def test_delete_purge_adds_flag(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.delete(purge=True) + assert "--purge" in backend.last_call() + + +def test_lifecycle_raises_on_failure(): + backend = FakeBackend() + backend.set_default(make_err("not running")) + vm = MultipassVM("my-vm", "multipass", backend) + with pytest.raises(MultipassCommandError): + vm.start() + + +# -------------------------------------------------------------- exec + +def test_exec_builds_command_from_list(): + exec_result = CommandResult( + args=["multipass", "exec", "my-vm", "--", "ls", "-la"], + returncode=0, + stdout="total 0\n", + stderr="", + ) + backend = FakeBackend( + {("multipass", "exec", "my-vm", "--", "ls", "-la"): exec_result} + ) + vm = MultipassVM("my-vm", "multipass", backend) + result = vm.exec(["ls", "-la"]) + assert result.stdout == "total 0\n" + assert result.success is True + + +def test_exec_raises_on_nonzero(): + backend = FakeBackend( + {("multipass", "exec", "my-vm", "--", "false"): make_err("", returncode=1)} + ) + vm = MultipassVM("my-vm", "multipass", backend) + with pytest.raises(MultipassCommandError): + vm.exec(["false"]) + + +# ------------------------------------------------------------ transfer + +def test_transfer_host_to_vm(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.transfer("/host/path/file.txt", "my-vm:/remote/path/") + assert backend.last_call() == [ + "multipass", "transfer", "-r", "/host/path/file.txt", "my-vm:/remote/path/" + ] + + +def test_transfer_vm_to_host(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.transfer("my-vm:/remote/file.txt", "/host/dest/") + assert backend.last_call() == [ + "multipass", "transfer", "-r", "my-vm:/remote/file.txt", "/host/dest/" + ] + + +# -------------------------------------------------------------- mount + +def test_mount_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.mount("/host/dir", "my-vm:/mnt/dir") + assert backend.last_call() == ["multipass", "mount", "/host/dir", "my-vm:/mnt/dir"] + + +def test_mount_with_options(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.mount("/host/dir", "my-vm:/mnt/dir", uid_map="1000:1000", gid_map="1000:1000") + call = backend.last_call() + assert "--uid-map" in call + assert "1000:1000" in call + + +def test_unmount_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.unmount("my-vm:/mnt/dir") + assert backend.last_call() == ["multipass", "umount", "my-vm:/mnt/dir"] + + +# ---------------------------------------------------------- snapshots + +def test_snapshots_returns_list(): + backend = FakeBackend( + {("multipass", "list", "--snapshots", "--format", "json"): make_ok(SNAPSHOTS_JSON)} + ) + vm = MultipassVM("my-vm", "multipass", backend) + snaps = vm.snapshots() + assert len(snaps) == 1 + assert snaps[0].name == "snap1" + + +def test_snapshot_create_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok(SNAPSHOTS_JSON)) + vm = MultipassVM("my-vm", "multipass", backend) + vm.snapshot("snap1", comment="Before upgrade") + calls = backend.calls + assert any(call[:3] == ["multipass", "snapshot", "my-vm"] for call in calls) + + +def test_restore_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.restore("snap1") + assert backend.last_call() == ["multipass", "restore", "my-vm.snap1"] + + +def test_restore_destructive_adds_flag(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.restore("snap1", destructive=True) + assert "--destructive" in backend.last_call() + + +# --------------------------------------------------------------- clone + +def test_clone_sends_correct_command_and_returns_vm(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + new_vm = vm.clone("my-vm-clone") + assert backend.last_call() == ["multipass", "clone", "my-vm", "--name", "my-vm-clone"] + assert new_vm.name == "my-vm-clone" From 3e573b48b2f2ed3dc281b4f444bd68fbad65dafa Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:13:49 +0200 Subject: [PATCH 06/24] feat: add MultipassClient with full command coverage and public API exports --- src/multipass/__init__.py | 44 +++++++- src/multipass/client.py | 122 ++++++++++++++++++++++ tests/unit/test_client.py | 209 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/multipass/client.py create mode 100644 tests/unit/test_client.py diff --git a/src/multipass/__init__.py b/src/multipass/__init__.py index 9361f58..f0cc886 100644 --- a/src/multipass/__init__.py +++ b/src/multipass/__init__.py @@ -1 +1,43 @@ -# populated in Task 9 +from .client import MultipassClient +from .vm import MultipassVM +from .exceptions import ( + MultipassError, + MultipassCommandError, + MultipassNotInstalledError, + VmNotFoundError, + VmAlreadyRunningError, + VmNotRunningError, + VmAlreadySuspendedError, +) +from .models import ( + VmInfo, + VmState, + ImageInfo, + NetworkInfo, + VersionInfo, + AliasInfo, + SnapshotInfo, +) +from ._backend import CommandResult, FakeBackend, SubprocessBackend + +__all__ = [ + "MultipassClient", + "MultipassVM", + "MultipassError", + "MultipassCommandError", + "MultipassNotInstalledError", + "VmNotFoundError", + "VmAlreadyRunningError", + "VmNotRunningError", + "VmAlreadySuspendedError", + "VmInfo", + "VmState", + "ImageInfo", + "NetworkInfo", + "VersionInfo", + "AliasInfo", + "SnapshotInfo", + "CommandResult", + "FakeBackend", + "SubprocessBackend", +] diff --git a/src/multipass/client.py b/src/multipass/client.py new file mode 100644 index 0000000..6d263c1 --- /dev/null +++ b/src/multipass/client.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json + +from haikunator import Haikunator + +from ._backend import CommandBackend, CommandResult, SubprocessBackend +from .exceptions import MultipassCommandError +from .models import AliasInfo, ImageInfo, NetworkInfo, VmInfo, VersionInfo +from .vm import MultipassVM + + +def _check(result: CommandResult) -> None: + """Raise MultipassCommandError on non-zero exit. Used for global (non-VM) commands.""" + if not result.success: + raise MultipassCommandError( + result.args, result.returncode, result.stdout, result.stderr + ) + + +class MultipassClient: + def __init__( + self, + cmd: str = "multipass", + backend: CommandBackend | None = None, + ) -> None: + self._cmd = cmd + self._backend: CommandBackend = backend or SubprocessBackend() + + # --------------------------------------------------------- VM factory + + def get_vm(self, name: str) -> MultipassVM: + return MultipassVM(name, self._cmd, self._backend) + + # ------------------------------------------------------------- launch + + def launch( + self, + name: str | None = None, + image: str | None = None, + *, + cpus: int = 1, + memory: str = "1G", + disk: str = "5G", + cloud_init: str | None = None, + ) -> MultipassVM: + if name is None: + name = Haikunator().haikunate(token_length=0) + cmd = [ + self._cmd, "launch", + "-n", name, + "-c", str(cpus), + "-m", memory, + "-d", disk, + ] + if cloud_init: + cmd += ["--cloud-init", cloud_init] + if image and image != "ubuntu-lts": + cmd.append(image) + result = self._backend.run(cmd) + _check(result) + return MultipassVM(name, self._cmd, self._backend) + + # --------------------------------------------------------------- list + + def list(self) -> list[VmInfo]: + result = self._backend.run([self._cmd, "list", "--format", "json"]) + _check(result) + return VmInfo.from_list_json(json.loads(result.stdout)) + + # --------------------------------------------------------------- find + + def find(self) -> list[ImageInfo]: + result = self._backend.run([self._cmd, "find", "--format", "json"]) + _check(result) + return ImageInfo.from_find_json(json.loads(result.stdout)) + + # -------------------------------------------------------------- purge + + def purge(self) -> None: + result = self._backend.run([self._cmd, "purge"]) + _check(result) + + # ------------------------------------------------------------ networks + + def networks(self) -> list[NetworkInfo]: + result = self._backend.run([self._cmd, "networks", "--format", "json"]) + _check(result) + return NetworkInfo.from_networks_json(json.loads(result.stdout)) + + # ------------------------------------------------------------- version + + def version(self) -> VersionInfo: + result = self._backend.run([self._cmd, "version", "--format", "json"]) + _check(result) + return VersionInfo.from_json(json.loads(result.stdout)) + + # ------------------------------------------------------------ get/set + + def get(self, key: str) -> str: + result = self._backend.run([self._cmd, "get", key]) + _check(result) + return result.stdout.strip() + + def set(self, key: str, value: str) -> None: + result = self._backend.run([self._cmd, "set", f"{key}={value}"]) + _check(result) + + # ------------------------------------------------------------ aliases + + def aliases(self) -> list[AliasInfo]: + result = self._backend.run([self._cmd, "aliases", "--format", "json"]) + _check(result) + return AliasInfo.from_aliases_json(json.loads(result.stdout)) + + def alias(self, name: str, vm: str, command: str) -> None: + result = self._backend.run([self._cmd, "alias", f"{vm}:{command}", name]) + _check(result) + + def unalias(self, name: str) -> None: + result = self._backend.run([self._cmd, "unalias", name]) + _check(result) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..27bd356 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,209 @@ +import json +import pytest +from multipass._backend import CommandResult, FakeBackend +from multipass.client import MultipassClient +from multipass.exceptions import MultipassCommandError +from multipass.models import VmState +from multipass.vm import MultipassVM + +LIST_JSON = json.dumps({ + "list": [ + {"ipv4": ["192.168.64.2"], "name": "vm1", "release": "22.04 LTS", "state": "Running"}, + {"ipv4": [], "name": "vm2", "release": "22.04 LTS", "state": "Stopped"}, + ] +}) + +FIND_JSON = json.dumps({ + "errors": [], + "images": { + "22.04": { + "aliases": ["jammy", "lts"], + "os": "Ubuntu", + "release": "22.04 LTS", + "remote": "", + "version": "20230801", + } + }, +}) + +NETWORKS_JSON = json.dumps({ + "list": [{"description": "Wi-Fi", "name": "en0", "type": "wifi"}] +}) + +VERSION_JSON = json.dumps({"multipass": "1.13.0", "multipassd": "1.13.0"}) + +ALIASES_JSON = json.dumps({ + "aliases": [{"alias": "myalias", "command": "ls", "instance": "vm1", "working-directory": "default"}] +}) + + +def make_ok(stdout: str = "") -> CommandResult: + return CommandResult(args=[], returncode=0, stdout=stdout, stderr="") + + +def make_err(stderr: str = "error") -> CommandResult: + return CommandResult(args=[], returncode=1, stdout="", stderr=stderr) + + +def test_list_returns_vm_info_list(): + backend = FakeBackend( + {("multipass", "list", "--format", "json"): make_ok(LIST_JSON)} + ) + client = MultipassClient(backend=backend) + vms = client.list() + assert len(vms) == 2 + assert vms[0].name == "vm1" + assert vms[0].state == VmState.RUNNING + assert vms[1].name == "vm2" + + +def test_find_returns_image_list(): + backend = FakeBackend( + {("multipass", "find", "--format", "json"): make_ok(FIND_JSON)} + ) + client = MultipassClient(backend=backend) + images = client.find() + assert len(images) == 1 + assert "jammy" in images[0].aliases + + +def test_get_vm_returns_multipass_vm(): + client = MultipassClient(backend=FakeBackend()) + vm = client.get_vm("my-vm") + assert isinstance(vm, MultipassVM) + assert vm.name == "my-vm" + + +def test_launch_default_name_generates_name(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + vm = client.launch() + assert vm.name + assert backend.calls[0][0] == "multipass" + assert backend.calls[0][1] == "launch" + + +def test_launch_with_explicit_name_and_resources(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + vm = client.launch(name="test-vm", cpus=4, memory="8G", disk="30G") + assert vm.name == "test-vm" + call = backend.last_call() + assert "test-vm" in call + assert "4" in call + assert "8G" in call + assert "30G" in call + + +def test_launch_with_cloud_init(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + client.launch(name="test-vm", cloud_init="/tmp/cloud-init.yaml") + call = backend.last_call() + assert "--cloud-init" in call + assert "/tmp/cloud-init.yaml" in call + + +def test_launch_raises_on_failure(): + backend = FakeBackend() + backend.set_default(make_err("launch failed")) + client = MultipassClient(backend=backend) + with pytest.raises(MultipassCommandError): + client.launch(name="bad-vm") + + +def test_purge_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + client.purge() + assert backend.last_call() == ["multipass", "purge"] + + +def test_networks_returns_list(): + backend = FakeBackend( + {("multipass", "networks", "--format", "json"): make_ok(NETWORKS_JSON)} + ) + client = MultipassClient(backend=backend) + nets = client.networks() + assert len(nets) == 1 + assert nets[0].name == "en0" + + +def test_version_returns_version_info(): + backend = FakeBackend( + {("multipass", "version", "--format", "json"): make_ok(VERSION_JSON)} + ) + client = MultipassClient(backend=backend) + v = client.version() + assert v.multipass == "1.13.0" + + +def test_get_returns_value(): + backend = FakeBackend( + {("multipass", "get", "local.bridged-network"): make_ok("eth0\n")} + ) + client = MultipassClient(backend=backend) + assert client.get("local.bridged-network") == "eth0" + + +def test_set_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + client.set("local.driver", "qemu") + assert backend.last_call() == ["multipass", "set", "local.driver=qemu"] + + +def test_aliases_returns_list(): + backend = FakeBackend( + {("multipass", "aliases", "--format", "json"): make_ok(ALIASES_JSON)} + ) + client = MultipassClient(backend=backend) + aliases = client.aliases() + assert len(aliases) == 1 + assert aliases[0].alias == "myalias" + + +def test_unalias_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + client.unalias("myalias") + assert backend.last_call() == ["multipass", "unalias", "myalias"] + + +def test_custom_multipass_cmd(): + backend = FakeBackend() + backend.set_default(make_ok(LIST_JSON)) + client = MultipassClient(cmd="/usr/local/bin/multipass", backend=backend) + client.list() + assert backend.calls[0][0] == "/usr/local/bin/multipass" + + +def test_public_api_importable(): + from multipass import ( + MultipassClient, + MultipassVM, + MultipassError, + MultipassCommandError, + MultipassNotInstalledError, + VmNotFoundError, + VmAlreadyRunningError, + VmNotRunningError, + VmAlreadySuspendedError, + VmInfo, + VmState, + ImageInfo, + NetworkInfo, + VersionInfo, + AliasInfo, + SnapshotInfo, + CommandResult, + FakeBackend, + SubprocessBackend, + ) + assert MultipassClient is not None From d5bc9e269666afa72048be504cd417110a816347 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:15:01 +0200 Subject: [PATCH 07/24] chore: add integration tests, remove legacy files, update CI to use uv --- .github/workflows/ci.yml | 14 +-- CLAUDE.md | 40 ++++++++ multipass.py | 136 -------------------------- requirements.txt | 3 +- setup.cfg | 2 - setup.py | 12 --- tests/integration/test_integration.py | 53 ++++++++++ 7 files changed, 102 insertions(+), 158 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 multipass.py delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 tests/integration/test_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 777b8fb..0476aeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,14 @@ on: branches: [ main ] jobs: - test_pull_request: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 with: - python-version: 3.8 + version: "latest" - name: Install dependencies - run: python -m pip install -r requirements.txt - - name: Check if multipass.py works - run: python multipass.py \ No newline at end of file + run: uv sync --extra dev + - name: Run unit tests + run: uv run pytest tests/unit/ -v --tb=short diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..870db3c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +An unofficial Python SDK that wraps the Multipass CLI to manage Ubuntu VMs programmatically. Full coverage of every Multipass CLI command. Designed to be imported by other projects (e.g. nanofaas) — it has no dependencies on them. + +## Setup + +```bash +uv sync --extra dev +``` + +Requires [uv](https://docs.astral.sh/uv/). Multipass itself is NOT required for unit tests. + +## Running Tests + +```bash +# Unit tests only (no Multipass required) +uv run pytest tests/unit/ -v + +# Single test +uv run pytest tests/unit/test_vm.py::test_exec_builds_command_from_list -v + +# Integration tests (requires Multipass installed) +uv run pytest -m integration -v +``` + +## Architecture + +`src/multipass/` contains five modules: + +- `_backend.py` — `CommandResult` dataclass, `CommandBackend` protocol, `SubprocessBackend` (real CLI), `FakeBackend` (for tests). All subprocess calls go through the backend. +- `exceptions.py` — Typed exception hierarchy rooted at `MultipassError`. +- `models.py` — Dataclasses (`VmInfo`, `VmState`, `ImageInfo`, etc.) with `from_*_json()` class methods that parse the actual Multipass CLI JSON output. +- `vm.py` — `MultipassVM`: per-VM operations (info, start, stop, restart, suspend, delete, recover, exec, transfer, mount, unmount, snapshot, restore, clone). +- `client.py` — `MultipassClient`: global operations (launch, list, find, purge, networks, version, get, set, aliases). + +`MultipassClient` creates `MultipassVM` instances and passes its backend down to them. Unit tests inject a `FakeBackend` configured with pre-built `CommandResult` responses. diff --git a/multipass.py b/multipass.py deleted file mode 100644 index 8f6f7d3..0000000 --- a/multipass.py +++ /dev/null @@ -1,136 +0,0 @@ -import subprocess -from haikunator import Haikunator -import os -import json - -class MultipassVM: - def __init__(self, vm_name, multipass_cmd): - self.cmd = multipass_cmd - self.vm_name = vm_name - def info(self): - cmd = [self.cmd, "info", self.vm_name, "--format", "json"] - out = subprocess.Popen(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - stdout,stderr = out.communicate() - exitcode = out.wait() - if(not exitcode == 0): - raise Exception("Multipass info command failed: {0}".format(stderr)) - return json.loads(stdout) - def delete(self): - cmd = [self.cmd, "delete", self.vm_name] - try: - subprocess.check_output(cmd) - except: - raise Exception("Error deleting Multipass VM {0}".format(vm_name)) - def shell(self): - raise Exception("The shell command is not supported in the Multipass SDK. Consider using exec.") - def exec(self, cmd_to_execute): - cmd = [self.cmd, "exec", self.vm_name] - cmd += cmd_to_execute.split(" ") - out = subprocess.Popen(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - stdout,stderr = out.communicate() - exitcode = out.wait() - if(not exitcode == 0): - raise Exception("Multipass exec command failed: {0}".format(stderr)) - return stdout, stderr - def stop(self): - cmd = [self.cmd, "stop", self.vm_name] - try: - subprocess.check_output(cmd) - except: - raise Exception("Error stopping Multipass VM {0}".format(vm_name)) - def start(self): - cmd = [self.cmd, "start", self.vm_name] - try: - subprocess.check_output(cmd) - except: - raise Exception("Error starting Multipass VM {0}".format(vm_name)) - def restart(self): - cmd = [self.cmd, "restart", self.vm_name] - try: - subprocess.check_output(cmd) - except: - raise Exception("Error restarting Multipass VM {0}".format(vm_name)) - -class MultipassClient: - """ - Multipass client - """ - def __init__(self, multipass_cmd="multipass"): - self.cmd = multipass_cmd - def launch(self, vm_name=None, cpu=1, disk="5G", mem="1G", image=None, cloud_init=None): - if(not vm_name): - # similar to Multipass's VM name generator - vm_name = Haikunator().haikunate(token_length=0) - cmd = [self.cmd, "launch", "-c", str(cpu), "-d", disk, "-n", vm_name, "-m", mem] - if(cloud_init): - cmd.append("--cloud-init") - cmd.append(cloud_init) - if(image and not image == "ubuntu-lts"): - cmd.append(image) - try: - subprocess.check_output(cmd) - except: - raise Exception("Error launching Multipass VM {0}".format(vm_name)) - return MultipassVM(vm_name, self.cmd) - def transfer(self, src, dest): - cmd = [self.cmd, "transfer", src, dest] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass transfer command failed.") - def get_vm(self, vm_name): - return MultipassVM(vm_name, self.cmd) - def purge(self): - cmd = [self.cmd, "purge"] - try: - subprocess.check_output(cmd) - except: - raise Exception("Purge command failed.") - def list(self): - cmd = [self.cmd, "list", "--format", "json"] - out = subprocess.Popen(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - stdout,stderr = out.communicate() - exitcode = out.wait() - if(not exitcode == 0): - raise Exception("Multipass list command failed: {0}".format(stderr)) - return json.loads(stdout) - def find(self): - cmd = [self.cmd, "find", "--format", "json"] - out = subprocess.Popen(cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - stdout,stderr = out.communicate() - exitcode = out.wait() - if(not exitcode == 0): - raise Exception("Multipass find command failed: {0}".format(stderr)) - return json.loads(stdout) - def mount(self, src, target): - cmd = [self.cmd, "mount", src, target] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass mount command failed.") - def unmount(self, mount): - cmd = [self.cmd, "unmount", mount] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass unmount command failed.") - def recover(self, vm_name): - cmd = [self.cmd, "recover", vm_name] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass recover command failed.") - def suspend(self): - cmd = [self.cmd, "suspend"] - try: - subprocess.check_output(cmd) - except: - raise Exception("Multipass suspend command failed.") diff --git a/requirements.txt b/requirements.txt index a7f56c1..ec44f6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -haikunator \ No newline at end of file +# Dependencies are managed via pyproject.toml +# Install dev dependencies with: uv sync --extra dev diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 224a779..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index cd643c0..0000000 --- a/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup - -setup(name='multipass-sdk', -version='0.2', -description='UNOFFICIAL Multipass Python SDK', -url='https://github.com/okyanusoz/multipass-sdk', -author='okyanusoz', -license='MIT', -py_modules=["multipass"], -scripts=["multipass.py"], -install_requires=["haikunator"], -zip_safe=False) \ No newline at end of file diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py new file mode 100644 index 0000000..a0bda5c --- /dev/null +++ b/tests/integration/test_integration.py @@ -0,0 +1,53 @@ +""" +Integration tests — require Multipass installed and running. +Run with: uv run pytest -m integration +""" +import pytest +from multipass import MultipassClient, VmState + + +@pytest.fixture(scope="module") +def client(): + return MultipassClient() + + +@pytest.mark.integration +def test_version(client): + v = client.version() + assert v.multipass + assert v.multipassd + + +@pytest.mark.integration +def test_find_returns_images(client): + images = client.find() + assert len(images) > 0 + + +@pytest.mark.integration +def test_list_returns_vms(client): + vms = client.list() + assert isinstance(vms, list) + + +@pytest.mark.integration +def test_vm_full_lifecycle(client): + """Launch → info → stop → start → exec → delete. Requires internet access.""" + vm = client.launch(name="sdk-test-vm", cpus=1, memory="512M", disk="3G", image="22.04") + try: + info = vm.info() + assert info.name == "sdk-test-vm" + assert info.state == VmState.RUNNING + + vm.stop() + info = vm.info() + assert info.state == VmState.STOPPED + + vm.start() + info = vm.info() + assert info.state == VmState.RUNNING + + result = vm.exec(["echo", "hello"]) + assert "hello" in result.stdout + finally: + vm.delete(purge=True) From d40a883f78d94c4f3d01b8f12dc95c3f742d0081 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:18:28 +0200 Subject: [PATCH 08/24] =?UTF-8?q?refactor:=20simplify=20backend,=20vm,=20c?= =?UTF-8?q?lient=20=E2=80=94=20extract=20=5Frun=20helpers,=20remove=20redu?= =?UTF-8?q?ndant=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/multipass/_backend.py | 4 -- src/multipass/client.py | 72 ++++++++++------------------------- src/multipass/models.py | 2 +- src/multipass/vm.py | 79 +++++++++++++-------------------------- 4 files changed, 46 insertions(+), 111 deletions(-) diff --git a/src/multipass/_backend.py b/src/multipass/_backend.py index 7078a5a..8010ef9 100644 --- a/src/multipass/_backend.py +++ b/src/multipass/_backend.py @@ -1,6 +1,5 @@ from __future__ import annotations -import shutil import subprocess from dataclasses import dataclass from typing import Protocol @@ -28,9 +27,6 @@ class SubprocessBackend: """Real backend — invokes the Multipass CLI via subprocess.""" def run(self, args: list[str]) -> CommandResult: - binary = args[0] if args else "multipass" - if not shutil.which(binary): - raise MultipassNotInstalledError() try: proc = subprocess.run(args, capture_output=True, text=True) except FileNotFoundError: diff --git a/src/multipass/client.py b/src/multipass/client.py index 6d263c1..0a8d161 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -11,7 +11,6 @@ def _check(result: CommandResult) -> None: - """Raise MultipassCommandError on non-zero exit. Used for global (non-VM) commands.""" if not result.success: raise MultipassCommandError( result.args, result.returncode, result.stdout, result.stderr @@ -27,13 +26,17 @@ def __init__( self._cmd = cmd self._backend: CommandBackend = backend or SubprocessBackend() - # --------------------------------------------------------- VM factory + def _run(self, *args: str) -> CommandResult: + result = self._backend.run([self._cmd, *args]) + _check(result) + return result + + def _run_json(self, *args: str) -> dict: + return json.loads(self._run(*args).stdout) def get_vm(self, name: str) -> MultipassVM: return MultipassVM(name, self._cmd, self._backend) - # ------------------------------------------------------------- launch - def launch( self, name: str | None = None, @@ -46,77 +49,40 @@ def launch( ) -> MultipassVM: if name is None: name = Haikunator().haikunate(token_length=0) - cmd = [ - self._cmd, "launch", - "-n", name, - "-c", str(cpus), - "-m", memory, - "-d", disk, - ] + cmd = ["launch", "-n", name, "-c", str(cpus), "-m", memory, "-d", disk] if cloud_init: cmd += ["--cloud-init", cloud_init] if image and image != "ubuntu-lts": cmd.append(image) - result = self._backend.run(cmd) - _check(result) + self._run(*cmd) return MultipassVM(name, self._cmd, self._backend) - # --------------------------------------------------------------- list - def list(self) -> list[VmInfo]: - result = self._backend.run([self._cmd, "list", "--format", "json"]) - _check(result) - return VmInfo.from_list_json(json.loads(result.stdout)) - - # --------------------------------------------------------------- find + return VmInfo.from_list_json(self._run_json("list", "--format", "json")) def find(self) -> list[ImageInfo]: - result = self._backend.run([self._cmd, "find", "--format", "json"]) - _check(result) - return ImageInfo.from_find_json(json.loads(result.stdout)) - - # -------------------------------------------------------------- purge + return ImageInfo.from_find_json(self._run_json("find", "--format", "json")) def purge(self) -> None: - result = self._backend.run([self._cmd, "purge"]) - _check(result) - - # ------------------------------------------------------------ networks + self._run("purge") def networks(self) -> list[NetworkInfo]: - result = self._backend.run([self._cmd, "networks", "--format", "json"]) - _check(result) - return NetworkInfo.from_networks_json(json.loads(result.stdout)) - - # ------------------------------------------------------------- version + return NetworkInfo.from_networks_json(self._run_json("networks", "--format", "json")) def version(self) -> VersionInfo: - result = self._backend.run([self._cmd, "version", "--format", "json"]) - _check(result) - return VersionInfo.from_json(json.loads(result.stdout)) - - # ------------------------------------------------------------ get/set + return VersionInfo.from_json(self._run_json("version", "--format", "json")) def get(self, key: str) -> str: - result = self._backend.run([self._cmd, "get", key]) - _check(result) - return result.stdout.strip() + return self._run("get", key).stdout.strip() def set(self, key: str, value: str) -> None: - result = self._backend.run([self._cmd, "set", f"{key}={value}"]) - _check(result) - - # ------------------------------------------------------------ aliases + self._run("set", f"{key}={value}") def aliases(self) -> list[AliasInfo]: - result = self._backend.run([self._cmd, "aliases", "--format", "json"]) - _check(result) - return AliasInfo.from_aliases_json(json.loads(result.stdout)) + return AliasInfo.from_aliases_json(self._run_json("aliases", "--format", "json")) def alias(self, name: str, vm: str, command: str) -> None: - result = self._backend.run([self._cmd, "alias", f"{vm}:{command}", name]) - _check(result) + self._run("alias", f"{vm}:{command}", name) def unalias(self, name: str) -> None: - result = self._backend.run([self._cmd, "unalias", name]) - _check(result) + self._run("unalias", name) diff --git a/src/multipass/models.py b/src/multipass/models.py index 2407a45..8197280 100644 --- a/src/multipass/models.py +++ b/src/multipass/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum diff --git a/src/multipass/vm.py b/src/multipass/vm.py index 5c39739..f746763 100644 --- a/src/multipass/vm.py +++ b/src/multipass/vm.py @@ -24,18 +24,17 @@ def __init__(self, name: str, cmd: str, backend: CommandBackend) -> None: self._cmd = cmd self._backend = backend - # ------------------------------------------------------------------ info + def _run(self, cmd: list[str]) -> CommandResult: + result = self._backend.run(cmd) + _raise_for_result(result, self.name) + return result def info(self) -> VmInfo: - result = self._backend.run([self._cmd, "info", self.name, "--format", "json"]) - _raise_for_result(result, self.name) + result = self._run([self._cmd, "info", self.name, "--format", "json"]) return VmInfo.from_info_json(json.loads(result.stdout), self.name) - # ------------------------------------------------------------ lifecycle - def start(self) -> None: - result = self._backend.run([self._cmd, "start", self.name]) - _raise_for_result(result, self.name) + self._run([self._cmd, "start", self.name]) def stop(self, *, force: bool = False, time: int | None = None) -> None: cmd = [self._cmd, "stop", self.name] @@ -43,38 +42,26 @@ def stop(self, *, force: bool = False, time: int | None = None) -> None: cmd.append("--force") if time is not None: cmd += ["--time", str(time)] - result = self._backend.run(cmd) - _raise_for_result(result, self.name) + self._run(cmd) def restart(self) -> None: - result = self._backend.run([self._cmd, "restart", self.name]) - _raise_for_result(result, self.name) + self._run([self._cmd, "restart", self.name]) def suspend(self) -> None: - result = self._backend.run([self._cmd, "suspend", self.name]) - _raise_for_result(result, self.name) + self._run([self._cmd, "suspend", self.name]) def delete(self, *, purge: bool = False) -> None: cmd = [self._cmd, "delete", self.name] if purge: cmd.append("--purge") - result = self._backend.run(cmd) - _raise_for_result(result, self.name) + self._run(cmd) def recover(self) -> None: - result = self._backend.run([self._cmd, "recover", self.name]) - _raise_for_result(result, self.name) - - # -------------------------------------------------------------- exec + self._run([self._cmd, "recover", self.name]) def exec(self, command: list[str]) -> CommandResult: """Execute a command in the VM. command must be a list of args (no shell splitting).""" - cmd = [self._cmd, "exec", self.name, "--"] + command - result = self._backend.run(cmd) - _raise_for_result(result, self.name) - return result - - # ------------------------------------------------------------ transfer + return self._run([self._cmd, "exec", self.name, "--"] + command) def transfer(self, source: str, dest: str) -> None: """Transfer files between host and VM. @@ -82,10 +69,7 @@ def transfer(self, source: str, dest: str) -> None: Use 'vm-name:/path' notation for VM paths, plain paths for host. Always recursive (-r). """ - result = self._backend.run([self._cmd, "transfer", "-r", source, dest]) - _raise_for_result(result, self.name) - - # -------------------------------------------------------------- mount + self._run([self._cmd, "transfer", "-r", source, dest]) def mount( self, @@ -103,45 +87,34 @@ def mount( cmd += ["--uid-map", uid_map] if gid_map: cmd += ["--gid-map", gid_map] - result = self._backend.run(cmd) - _raise_for_result(result, self.name) + self._run(cmd) def unmount(self, mount: str) -> None: - result = self._backend.run([self._cmd, "umount", mount]) - _raise_for_result(result, self.name) - - # ---------------------------------------------------------- snapshots + self._run([self._cmd, "umount", mount]) def snapshots(self) -> list[SnapshotInfo]: - result = self._backend.run( - [self._cmd, "list", "--snapshots", "--format", "json"] - ) - _raise_for_result(result, self.name) + result = self._run([self._cmd, "list", "--snapshots", "--format", "json"]) return SnapshotInfo.from_snapshots_json(json.loads(result.stdout)) def snapshot(self, name: str, *, comment: str | None = None) -> SnapshotInfo: cmd = [self._cmd, "snapshot", self.name, "--name", name] if comment: cmd += ["--comment", comment] - result = self._backend.run(cmd) - _raise_for_result(result, self.name) - all_snaps = self.snapshots() - for snap in all_snaps: - if snap.name == name: - return snap - raise MultipassCommandError(cmd, 0, "", f"Snapshot '{name}' not found after creation") + self._run(cmd) + return SnapshotInfo( + name=name, + comment=comment or "", + created="", + parent=None, + instance=self.name, + ) def restore(self, snapshot: str, *, destructive: bool = False) -> None: cmd = [self._cmd, "restore", f"{self.name}.{snapshot}"] if destructive: cmd.append("--destructive") - result = self._backend.run(cmd) - _raise_for_result(result, self.name) - - # --------------------------------------------------------------- clone + self._run(cmd) def clone(self, new_name: str) -> "MultipassVM": - cmd = [self._cmd, "clone", self.name, "--name", new_name] - result = self._backend.run(cmd) - _raise_for_result(result, self.name) + self._run([self._cmd, "clone", self.name, "--name", new_name]) return MultipassVM(new_name, self._cmd, self._backend) From 0bd15dc434e8587b2dcc0bc4308cc429d67b1ce8 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:21:12 +0200 Subject: [PATCH 09/24] test: add missing tests for restart, suspend, recover, alias --- tests/unit/test_client.py | 8 ++++++++ tests/unit/test_vm.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 27bd356..13edb45 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -168,6 +168,14 @@ def test_aliases_returns_list(): assert aliases[0].alias == "myalias" +def test_alias_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + client.alias("myalias", "vm1", "ls") + assert backend.last_call() == ["multipass", "alias", "vm1:ls", "myalias"] + + def test_unalias_sends_correct_command(): backend = FakeBackend() backend.set_default(make_ok()) diff --git a/tests/unit/test_vm.py b/tests/unit/test_vm.py index 4fb43be..7558c7d 100644 --- a/tests/unit/test_vm.py +++ b/tests/unit/test_vm.py @@ -99,6 +99,30 @@ def test_stop_force_adds_flag(): assert "--force" in backend.last_call() +def test_restart_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.restart() + assert backend.last_call() == ["multipass", "restart", "my-vm"] + + +def test_suspend_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.suspend() + assert backend.last_call() == ["multipass", "suspend", "my-vm"] + + +def test_recover_sends_correct_command(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.recover() + assert backend.last_call() == ["multipass", "recover", "my-vm"] + + def test_delete_sends_correct_command(): backend = FakeBackend() backend.set_default(make_ok()) From 1e745cb7785c7a1238364108b35b6e14f524915c Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:29:54 +0200 Subject: [PATCH 10/24] feat: add wait_for_ip and wait_ready for reliable SSH connectivity --- src/multipass/__init__.py | 2 + src/multipass/_backend.py | 7 +++ src/multipass/exceptions.py | 7 +++ src/multipass/vm.py | 31 +++++++++- tests/integration/test_integration.py | 40 ++++++++++++- tests/unit/test_vm.py | 84 ++++++++++++++++++++++++++- 6 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/multipass/__init__.py b/src/multipass/__init__.py index f0cc886..9a01e7b 100644 --- a/src/multipass/__init__.py +++ b/src/multipass/__init__.py @@ -4,6 +4,7 @@ MultipassError, MultipassCommandError, MultipassNotInstalledError, + MultipassTimeoutError, VmNotFoundError, VmAlreadyRunningError, VmNotRunningError, @@ -26,6 +27,7 @@ "MultipassError", "MultipassCommandError", "MultipassNotInstalledError", + "MultipassTimeoutError", "VmNotFoundError", "VmAlreadyRunningError", "VmNotRunningError", diff --git a/src/multipass/_backend.py b/src/multipass/_backend.py index 8010ef9..76c13ef 100644 --- a/src/multipass/_backend.py +++ b/src/multipass/_backend.py @@ -47,15 +47,22 @@ def __init__( responses: dict[tuple[str, ...], CommandResult] | None = None, ) -> None: self._responses: dict[tuple[str, ...], CommandResult] = responses or {} + self._queues: dict[tuple[str, ...], list[CommandResult]] = {} self._calls: list[list[str]] = [] self._default: CommandResult | None = None def set_default(self, result: CommandResult) -> None: self._default = result + def push(self, *args: str, result: CommandResult) -> None: + """Queue a response for args (consumed in order, takes priority over responses/default).""" + self._queues.setdefault(args, []).append(result) + def run(self, args: list[str]) -> CommandResult: self._calls.append(list(args)) key = tuple(args) + if key in self._queues and self._queues[key]: + return self._queues[key].pop(0) if key in self._responses: return self._responses[key] if self._default is not None: diff --git a/src/multipass/exceptions.py b/src/multipass/exceptions.py index 44c512a..aa54d50 100644 --- a/src/multipass/exceptions.py +++ b/src/multipass/exceptions.py @@ -42,3 +42,10 @@ class VmAlreadySuspendedError(MultipassError): def __init__(self, name: str) -> None: self.name = name super().__init__(f"VM '{name}' is already suspended") + + +class MultipassTimeoutError(MultipassError): + def __init__(self, name: str, timeout: float) -> None: + self.name = name + self.timeout = timeout + super().__init__(f"VM '{name}' did not become ready within {timeout}s") diff --git a/src/multipass/vm.py b/src/multipass/vm.py index f746763..872eeac 100644 --- a/src/multipass/vm.py +++ b/src/multipass/vm.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import socket +import time -from .exceptions import MultipassCommandError, VmNotFoundError +from .exceptions import MultipassCommandError, MultipassError, MultipassTimeoutError, VmNotFoundError from .models import SnapshotInfo, VmInfo from ._backend import CommandBackend, CommandResult @@ -118,3 +120,30 @@ def restore(self, snapshot: str, *, destructive: bool = False) -> None: def clone(self, new_name: str) -> "MultipassVM": self._run([self._cmd, "clone", self.name, "--name", new_name]) return MultipassVM(new_name, self._cmd, self._backend) + + def wait_for_ip(self, timeout: float = 120, *, interval: float = 2.0) -> str: + """Poll info() until the VM has an IPv4 address. Returns the first IP.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + ip_list = self.info().ipv4 + if ip_list: + return ip_list[0] + except MultipassError: + pass + time.sleep(interval) + raise MultipassTimeoutError(self.name, timeout) + + def wait_ready(self, timeout: float = 120, port: int = 22, *, interval: float = 2.0) -> str: + """Poll until VM has IP AND TCP port is reachable. Returns the IP.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + ip_list = self.info().ipv4 + if ip_list: + with socket.create_connection((ip_list[0], port), timeout=1): + return ip_list[0] + except (MultipassError, OSError): + pass + time.sleep(interval) + raise MultipassTimeoutError(self.name, timeout) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index a0bda5c..2bf9117 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -2,8 +2,9 @@ Integration tests — require Multipass installed and running. Run with: uv run pytest -m integration """ +import socket as _socket import pytest -from multipass import MultipassClient, VmState +from multipass import MultipassClient, VmNotFoundError, VmState @pytest.fixture(scope="module") @@ -51,3 +52,40 @@ def test_vm_full_lifecycle(client): assert "hello" in result.stdout finally: vm.delete(purge=True) + + +@pytest.mark.integration +def test_delete_soft_then_purge(client): + """Soft delete → verifica stato Deleted → purge globale → VM non trovata.""" + vm = client.launch(name="sdk-delete-test", cpus=1, memory="512M", disk="3G") + vm.delete() + info = vm.info() + assert info.state == VmState.DELETED + client.purge() + with pytest.raises(VmNotFoundError): + vm.info() + + +@pytest.mark.integration +def test_wait_for_ip_after_launch(client): + """wait_for_ip restituisce un IPv4 valido dopo il launch.""" + vm = client.launch(name="sdk-ip-test", cpus=1, memory="512M", disk="3G") + try: + ip = vm.wait_for_ip(timeout=120) + assert ip + assert "." in ip + finally: + vm.delete(purge=True) + + +@pytest.mark.integration +def test_wait_ready_ssh_reachable(client): + """wait_ready attende finché la porta 22 è aperta, poi verifica con TCP diretto.""" + vm = client.launch(name="sdk-ssh-test", cpus=1, memory="512M", disk="3G") + try: + ip = vm.wait_ready(timeout=180, port=22) + assert ip + with _socket.create_connection((ip, 22), timeout=5): + pass # connessione TCP verificata indipendentemente dall'SDK + finally: + vm.delete(purge=True) diff --git a/tests/unit/test_vm.py b/tests/unit/test_vm.py index 7558c7d..1b5f875 100644 --- a/tests/unit/test_vm.py +++ b/tests/unit/test_vm.py @@ -1,7 +1,8 @@ import json import pytest +from unittest.mock import MagicMock, patch from multipass._backend import CommandResult, FakeBackend -from multipass.exceptions import MultipassCommandError, VmNotFoundError +from multipass.exceptions import MultipassCommandError, MultipassTimeoutError, VmNotFoundError from multipass.models import VmState from multipass.vm import MultipassVM @@ -21,6 +22,16 @@ }, }) +_VM_DATA = json.loads(INFO_RESPONSE)["info"]["my-vm"] + +INFO_NO_IP = json.dumps({ + "errors": [], "info": {"my-vm": {**_VM_DATA, "ipv4": []}} +}) + +INFO_WITH_IP = json.dumps({ + "errors": [], "info": {"my-vm": {**_VM_DATA, "ipv4": ["192.168.64.5"]}} +}) + SNAPSHOTS_JSON = json.dumps({ "list": [ { @@ -270,3 +281,74 @@ def test_clone_sends_correct_command_and_returns_vm(): new_vm = vm.clone("my-vm-clone") assert backend.last_call() == ["multipass", "clone", "my-vm", "--name", "my-vm-clone"] assert new_vm.name == "my-vm-clone" + + +# -------------------------------------------------------- wait_for_ip + +INFO_KEY = ("multipass", "info", "my-vm", "--format", "json") + + +@patch("multipass.vm.time.sleep") +def test_wait_for_ip_returns_ip_when_ready(mock_sleep): + backend = FakeBackend() + backend.push(*INFO_KEY, result=make_ok(INFO_NO_IP)) + backend.push(*INFO_KEY, result=make_ok(INFO_WITH_IP)) + vm = MultipassVM("my-vm", "multipass", backend) + with patch("multipass.vm.time.monotonic", side_effect=[0, 10, 20]): + ip = vm.wait_for_ip(timeout=120, interval=2.0) + assert ip == "192.168.64.5" + mock_sleep.assert_called_once_with(2.0) + + +@patch("multipass.vm.time.sleep") +def test_wait_for_ip_raises_timeout(mock_sleep): + backend = FakeBackend() + backend.set_default(make_ok(INFO_NO_IP)) + vm = MultipassVM("my-vm", "multipass", backend) + with patch("multipass.vm.time.monotonic", side_effect=[0, 130]): + with pytest.raises(MultipassTimeoutError) as exc_info: + vm.wait_for_ip(timeout=120) + assert exc_info.value.name == "my-vm" + assert exc_info.value.timeout == 120 + + +# -------------------------------------------------------- wait_ready + +@patch("multipass.vm.time.sleep") +@patch("multipass.vm.socket.create_connection") +def test_wait_ready_returns_ip_when_ssh_reachable(mock_conn, mock_sleep): + backend = FakeBackend() + backend.set_default(make_ok(INFO_WITH_IP)) + mock_conn.return_value.__enter__ = MagicMock(return_value=None) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + vm = MultipassVM("my-vm", "multipass", backend) + with patch("multipass.vm.time.monotonic", side_effect=[0, 10]): + ip = vm.wait_ready(timeout=120, port=22) + assert ip == "192.168.64.5" + mock_conn.assert_called_once_with(("192.168.64.5", 22), timeout=1) + + +@patch("multipass.vm.time.sleep") +@patch("multipass.vm.socket.create_connection", side_effect=OSError) +def test_wait_ready_raises_timeout_when_port_unreachable(mock_conn, mock_sleep): + backend = FakeBackend() + backend.set_default(make_ok(INFO_WITH_IP)) + vm = MultipassVM("my-vm", "multipass", backend) + with patch("multipass.vm.time.monotonic", side_effect=[0, 130]): + with pytest.raises(MultipassTimeoutError): + vm.wait_ready(timeout=120, port=22) + + +@patch("multipass.vm.time.sleep") +@patch("multipass.vm.socket.create_connection") +def test_wait_ready_waits_for_ip_before_tcp(mock_conn, mock_sleep): + backend = FakeBackend() + backend.push(*INFO_KEY, result=make_ok(INFO_NO_IP)) + backend.push(*INFO_KEY, result=make_ok(INFO_WITH_IP)) + mock_conn.return_value.__enter__ = MagicMock(return_value=None) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + vm = MultipassVM("my-vm", "multipass", backend) + with patch("multipass.vm.time.monotonic", side_effect=[0, 10, 20]): + ip = vm.wait_ready(timeout=120, port=22) + assert ip == "192.168.64.5" + mock_conn.assert_called_once() # TCP check solo quando IP disponibile From 199e84bc25842ebd533d6b485bbad21ab94dce11 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:13:24 +0200 Subject: [PATCH 11/24] Fix snapshot JSON format and add missing integration test scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix SnapshotInfo.from_snapshots_json to match actual Multipass CLI format (nested dict info.{instance}.{snap_name} instead of flat list) - Fix cpu_count parsing to handle empty string on stopped VMs - Update integration tests disk size 3G→5G (Ubuntu 22.04 minimum) - Add 8 new integration test scenarios: resources, suspend/resume, file transfer, snapshot/restore, clone, cloud-init, error handling - Update unit test fixtures to use correct snapshot JSON format Co-Authored-By: Claude Sonnet 4.6 --- src/multipass/models.py | 23 ++-- tests/integration/test_integration.py | 182 ++++++++++++++++++++++++-- tests/unit/test_models.py | 18 +-- tests/unit/test_vm.py | 17 +-- 4 files changed, 205 insertions(+), 35 deletions(-) diff --git a/src/multipass/models.py b/src/multipass/models.py index 8197280..009d254 100644 --- a/src/multipass/models.py +++ b/src/multipass/models.py @@ -44,7 +44,7 @@ def from_info_json(cls, data: dict, name: str) -> "VmInfo": ipv4=vm.get("ipv4", []), image=vm.get("image_release", ""), image_hash=vm.get("image_hash", ""), - cpus=int(vm.get("cpu_count", 1)), + cpus=int(vm.get("cpu_count") or 0), memory_total=str(memory.get("total", 0)), memory_used=str(memory.get("used", 0)), disk_total=str(first_disk.get("total", "0")), @@ -158,13 +158,14 @@ class SnapshotInfo: @classmethod def from_snapshots_json(cls, data: dict) -> list["SnapshotInfo"]: - return [ - cls( - name=item["name"], - comment=item.get("comment", ""), - created=item.get("created", ""), - parent=item.get("parent"), - instance=item.get("instance", ""), - ) - for item in data.get("list", []) - ] + result = [] + for instance_name, snapshots in data.get("info", {}).items(): + for snap_name, snap_data in snapshots.items(): + result.append(cls( + name=snap_name, + comment=snap_data.get("comment", ""), + created=snap_data.get("created", ""), + parent=snap_data.get("parent") or None, + instance=instance_name, + )) + return result diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 2bf9117..ccc0fb2 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -3,8 +3,12 @@ Run with: uv run pytest -m integration """ import socket as _socket +import tempfile +import textwrap +from pathlib import Path + import pytest -from multipass import MultipassClient, VmNotFoundError, VmState +from multipass import MultipassClient, MultipassCommandError, VmNotFoundError, VmState @pytest.fixture(scope="module") @@ -12,6 +16,8 @@ def client(): return MultipassClient() +# ------------------------------------------------------------------ read-only + @pytest.mark.integration def test_version(client): v = client.version() @@ -31,10 +37,12 @@ def test_list_returns_vms(client): assert isinstance(vms, list) +# -------------------------------------------------------------- full lifecycle + @pytest.mark.integration def test_vm_full_lifecycle(client): - """Launch → info → stop → start → exec → delete. Requires internet access.""" - vm = client.launch(name="sdk-test-vm", cpus=1, memory="512M", disk="3G", image="22.04") + """Launch → info → stop → start → exec → delete.""" + vm = client.launch(name="sdk-test-vm", cpus=1, memory="512M", disk="5G", image="22.04") try: info = vm.info() assert info.name == "sdk-test-vm" @@ -54,10 +62,25 @@ def test_vm_full_lifecycle(client): vm.delete(purge=True) +@pytest.mark.integration +def test_vm_info_reports_resources(client): + """info() riporta correttamente cpu_count, memoria e disco.""" + vm = client.launch(name="sdk-resources-vm", cpus=2, memory="512M", disk="5G") + try: + info = vm.info() + assert info.cpus == 2 + assert int(info.disk_total) > 0 + assert int(info.memory_total) > 0 + finally: + vm.delete(purge=True) + + +# ------------------------------------------------------------- delete / purge + @pytest.mark.integration def test_delete_soft_then_purge(client): - """Soft delete → verifica stato Deleted → purge globale → VM non trovata.""" - vm = client.launch(name="sdk-delete-test", cpus=1, memory="512M", disk="3G") + """Soft delete → stato Deleted → purge globale → VM non trovata.""" + vm = client.launch(name="sdk-delete-test", cpus=1, memory="512M", disk="5G") vm.delete() info = vm.info() assert info.state == VmState.DELETED @@ -66,10 +89,12 @@ def test_delete_soft_then_purge(client): vm.info() +# --------------------------------------------------------------- wait helpers + @pytest.mark.integration def test_wait_for_ip_after_launch(client): """wait_for_ip restituisce un IPv4 valido dopo il launch.""" - vm = client.launch(name="sdk-ip-test", cpus=1, memory="512M", disk="3G") + vm = client.launch(name="sdk-ip-test", cpus=1, memory="512M", disk="5G") try: ip = vm.wait_for_ip(timeout=120) assert ip @@ -81,11 +106,152 @@ def test_wait_for_ip_after_launch(client): @pytest.mark.integration def test_wait_ready_ssh_reachable(client): """wait_ready attende finché la porta 22 è aperta, poi verifica con TCP diretto.""" - vm = client.launch(name="sdk-ssh-test", cpus=1, memory="512M", disk="3G") + vm = client.launch(name="sdk-ssh-test", cpus=1, memory="512M", disk="5G") try: ip = vm.wait_ready(timeout=180, port=22) assert ip with _socket.create_connection((ip, 22), timeout=5): - pass # connessione TCP verificata indipendentemente dall'SDK + pass finally: vm.delete(purge=True) + + +# ------------------------------------------------------------- suspend/resume + +@pytest.mark.integration +def test_suspend_and_resume(client): + """Suspend → stato Suspended → start → stato Running.""" + vm = client.launch(name="sdk-suspend-vm", cpus=1, memory="512M", disk="5G") + try: + vm.suspend() + info = vm.info() + assert info.state == VmState.SUSPENDED + + vm.start() + info = vm.info() + assert info.state == VmState.RUNNING + finally: + vm.delete(purge=True) + + +# ----------------------------------------------------------- file transfer + +@pytest.mark.integration +def test_transfer_host_to_vm_and_back(client): + """Trasferisce un file host→VM, legge il contenuto con exec, trasferisce VM→host.""" + vm = client.launch(name="sdk-transfer-vm", cpus=1, memory="512M", disk="5G") + try: + vm.wait_ready(timeout=180, port=22) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("sdk-transfer-test\n") + src_path = f.name + + vm.transfer(src_path, "sdk-transfer-vm:/tmp/sdk-test.txt") + result = vm.exec(["cat", "/tmp/sdk-test.txt"]) + assert "sdk-transfer-test" in result.stdout + + with tempfile.TemporaryDirectory() as dst_dir: + vm.transfer("sdk-transfer-vm:/tmp/sdk-test.txt", dst_dir + "/") + content = Path(dst_dir, "sdk-test.txt").read_text() + assert "sdk-transfer-test" in content + finally: + vm.delete(purge=True) + + +# ---------------------------------------------------------- snapshot / restore + +@pytest.mark.integration +def test_snapshot_and_restore(client): + """Crea un file, snapshot, modifica il file, restore, verifica che il file originale sia tornato.""" + vm = client.launch(name="sdk-snap-vm", cpus=1, memory="512M", disk="5G") + try: + vm.wait_ready(timeout=180, port=22) + + vm.exec(["bash", "-c", "echo original > /home/ubuntu/snaptest.txt"]) + vm.stop() + snap = vm.snapshot("snap1", comment="before-change") + assert snap.name == "snap1" + + # verifica che lo snapshot compaia nella lista mentre la VM è ferma + snaps = vm.snapshots() + assert any(s.name == "snap1" for s in snaps) + + vm.start() + vm.wait_ready(timeout=120, port=22) + + vm.exec(["bash", "-c", "echo modified > /home/ubuntu/snaptest.txt"]) + result = vm.exec(["cat", "/home/ubuntu/snaptest.txt"]) + assert "modified" in result.stdout + + vm.stop() + # --destructive consuma lo snapshot durante il restore + vm.restore("snap1", destructive=True) + vm.start() + vm.wait_ready(timeout=120, port=22) + + result = vm.exec(["cat", "/home/ubuntu/snaptest.txt"]) + assert "original" in result.stdout + finally: + vm.delete(purge=True) + + +# -------------------------------------------------------------------- clone + +@pytest.mark.integration +def test_clone_creates_independent_vm(client): + """Clona una VM e verifica che il clone sia indipendente.""" + vm = client.launch(name="sdk-clone-src", cpus=1, memory="512M", disk="5G") + clone = None + try: + vm.stop() + clone = vm.clone("sdk-clone-dst") + clone.start() + info = clone.info() + assert info.name == "sdk-clone-dst" + assert info.state == VmState.RUNNING + finally: + vm.delete(purge=True) + if clone: + clone.delete(purge=True) + + +# ------------------------------------------------------------- cloud-init + +@pytest.mark.integration +def test_cloud_init_creates_file(client): + """cloud-init scrive un file al primo avvio; exec verifica che esista.""" + cloud_init = textwrap.dedent("""\ + #cloud-config + write_files: + - path: /tmp/cloud-init-marker + content: "hello from cloud-init\n" + """) + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(cloud_init) + ci_path = f.name + + vm = client.launch(name="sdk-cloudinit-vm", cpus=1, memory="512M", disk="5G", cloud_init=ci_path) + try: + vm.wait_ready(timeout=180, port=22) + result = vm.exec(["cat", "/tmp/cloud-init-marker"]) + assert "hello from cloud-init" in result.stdout + finally: + vm.delete(purge=True) + + +# ----------------------------------------------------------- error handling + +@pytest.mark.integration +def test_launch_nonexistent_image_raises(client): + """Lanciare un'immagine inesistente solleva MultipassCommandError.""" + with pytest.raises(MultipassCommandError): + client.launch(name="sdk-err-vm", image="this-image-does-not-exist-xyz") + + +@pytest.mark.integration +def test_get_nonexistent_vm_raises(client): + """info() su una VM inesistente solleva VmNotFoundError.""" + vm = client.get_vm("sdk-ghost-vm-that-does-not-exist") + with pytest.raises(VmNotFoundError): + vm.info() diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ffff2d4..c4acdbc 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -96,17 +96,19 @@ def test_alias_info_from_json(): def test_snapshot_info_from_json(): data = { - "list": [ - { - "comment": "Before upgrade", - "created": "2023-08-15T10:30:00.000Z", - "instance": "my-vm", - "name": "snapshot1", - "parent": None, + "errors": [], + "info": { + "my-vm": { + "snapshot1": { + "comment": "Before upgrade", + "created": "2023-08-15T10:30:00.000Z", + "parent": "", + } } - ] + }, } snaps = SnapshotInfo.from_snapshots_json(data) assert len(snaps) == 1 assert snaps[0].name == "snapshot1" + assert snaps[0].instance == "my-vm" assert snaps[0].parent is None diff --git a/tests/unit/test_vm.py b/tests/unit/test_vm.py index 1b5f875..8b5f7ab 100644 --- a/tests/unit/test_vm.py +++ b/tests/unit/test_vm.py @@ -33,15 +33,16 @@ }) SNAPSHOTS_JSON = json.dumps({ - "list": [ - { - "comment": "Before upgrade", - "created": "2023-08-15T10:30:00.000Z", - "instance": "my-vm", - "name": "snap1", - "parent": None, + "errors": [], + "info": { + "my-vm": { + "snap1": { + "comment": "Before upgrade", + "created": "2023-08-15T10:30:00.000Z", + "parent": "", + } } - ] + } }) From 62b538cd08223075cdb872e7da1b29ca221f9fc5 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:18:51 +0200 Subject: [PATCH 12/24] Update README with complete API reference and installation guide Co-Authored-By: Claude Sonnet 4.6 --- README.md | 221 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 191 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bc801db..5e16f56 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,216 @@ -# UNOFFICIAL Multipass Python SDK +# multipass-sdk -This is a Multipass CLI wrapper for Python 3. +Unofficial Python SDK for [Canonical Multipass](https://multipass.run). Wraps the Multipass CLI to manage Ubuntu VMs programmatically. + +- Full coverage of the Multipass CLI (launch, list, find, exec, transfer, mount, snapshot, clone, …) +- Testable without Multipass installed — inject a `FakeBackend` in unit tests +- Typed exception hierarchy, dataclass models, no external runtime dependencies beyond `haikunator` + +--- ## Installation -You need Multipass to use this SDK. +### From GitHub (recommended for internal projects) -Simply run: +```bash +uv add git+https://github.com/miciav/multipass-sdk.git ``` -pip install multipass-sdk + +To pin to a specific commit or tag: + +```bash +uv add git+https://github.com/miciav/multipass-sdk.git@v0.3.0 ``` -Or, to install from source: +Multipass itself must be installed on the machine where the SDK is used at runtime. It is **not** required for unit tests. +--- -Clone this repo: +## Quick start +```python +from multipass import MultipassClient + +client = MultipassClient() + +# Launch a VM (name is auto-generated if omitted) +vm = client.launch(name="my-vm", cpus=2, memory="1G", disk="10G", image="22.04") + +# Wait until SSH is reachable, then connect +ip = vm.wait_ready(timeout=180, port=22) +print(f"VM ready at {ip}") + +# Run a command +result = vm.exec(["uname", "-r"]) +print(result.stdout) + +# Lifecycle +vm.stop() +vm.start() +vm.suspend() +vm.restart() + +# Cleanup +vm.delete(purge=True) ``` -git clone --depth=1 https://github.com/okyanusoz/multipass-sdk # or gh clone okyanusoz/multipass-sdk if you have the Github CLI installed + +--- + +## API reference + +### `MultipassClient` + +```python +client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary ``` +| Method | Description | +|--------|-------------| +| `launch(name, image, *, cpus, memory, disk, cloud_init) → MultipassVM` | Launch a new VM | +| `get_vm(name) → MultipassVM` | Get a VM object by name | +| `list() → list[VmInfo]` | List all VMs | +| `find() → list[ImageInfo]` | List available images | +| `purge()` | Permanently delete all soft-deleted VMs | +| `networks() → list[NetworkInfo]` | List available networks | +| `version() → VersionInfo` | Multipass and multipassd versions | +| `get(key) → str` | Read a Multipass setting | +| `set(key, value)` | Write a Multipass setting | +| `aliases() → list[AliasInfo]` | List all aliases | +| `alias(name, vm, command)` | Create an alias | +| `unalias(name)` | Remove an alias | + +### `MultipassVM` -Then run: +| Method | Description | +|--------|-------------| +| `info() → VmInfo` | Get current VM state and metadata | +| `start()` | Start the VM | +| `stop(*, force, time)` | Stop the VM | +| `restart()` | Restart the VM | +| `suspend()` | Suspend the VM | +| `recover()` | Recover a VM in error state | +| `delete(*, purge)` | Delete the VM (soft or permanent) | +| `exec(command: list[str]) → CommandResult` | Run a command in the VM | +| `transfer(source, dest)` | Transfer files between host and VM (recursive) | +| `mount(source, target, *, mount_type, uid_map, gid_map)` | Mount a host directory | +| `unmount(mount)` | Unmount a directory | +| `snapshots() → list[SnapshotInfo]` | List snapshots | +| `snapshot(name, *, comment) → SnapshotInfo` | Create a snapshot (VM must be stopped) | +| `restore(snapshot, *, destructive)` | Restore a snapshot | +| `clone(new_name) → MultipassVM` | Clone the VM (VM must be stopped) | +| `wait_for_ip(timeout, *, interval) → str` | Poll until the VM has an IPv4 address | +| `wait_ready(timeout, port, *, interval) → str` | Poll until the VM has an IP and a TCP port is reachable | + +### `wait_for_ip` / `wait_ready` + +These methods make the common "launch → SSH" pattern reliable: + +```python +vm = client.launch(name="worker", disk="10G") + +# Returns the first IPv4 address once assigned +ip = vm.wait_for_ip(timeout=120) + +# Returns the IP once the given TCP port is reachable (default: 22) +ip = vm.wait_ready(timeout=180, port=22) ``` -python setup.py install + +Both raise `MultipassTimeoutError` if the deadline is exceeded. + +### File transfer + +Use `instance-name:/path` notation for VM paths, plain paths for host paths: + +```python +vm.transfer("/host/file.txt", "my-vm:/home/ubuntu/file.txt") +vm.transfer("my-vm:/home/ubuntu/output.txt", "/host/dest/") ``` -## Usage +### Snapshots -Usage is really simple: +```python +vm.stop() +snap = vm.snapshot("snap1", comment="before upgrade") +vm.start() + +# restore (--destructive consumes the snapshot) +vm.stop() +vm.restore("snap1", destructive=True) +vm.start() +``` + +### cloud-init ```python -from multipass import MultipassClient -client = MultipassClient() -# or client = MultipassClient("") if your Multipass CLI name is diffrent -# Launch a VM(uses haikunator to generate VM names if you don't specify the name) -vm = client.launch(image="ubuntu-lts") -# Get info about the VM -print(vm.info()) -# Run something in the VM(returns the response back in bytes) -vm.exec("") -# Delete the VM -vm.delete() -client.purge() -# List VMs -print(client.list()) -# Get a VM Object -vm = client.get_vm("") -# For even more commands, see multipass.py +vm = client.launch(name="configured-vm", cloud_init="/path/to/cloud-init.yaml") ``` +--- + +## Exceptions + +All exceptions inherit from `MultipassError`. + +| Exception | When raised | +|-----------|-------------| +| `MultipassCommandError` | CLI exits with non-zero status | +| `MultipassNotInstalledError` | `multipass` binary not found | +| `MultipassTimeoutError` | `wait_for_ip` / `wait_ready` deadline exceeded | +| `VmNotFoundError` | VM does not exist | +| `VmAlreadyRunningError` | Operation requires stopped VM | +| `VmNotRunningError` | Operation requires running VM | +| `VmAlreadySuspendedError` | VM is already suspended | + +--- + +## Testing without Multipass + +Inject a `FakeBackend` to unit-test code that uses the SDK: + +```python +import json +from multipass import MultipassClient, FakeBackend, CommandResult + +backend = FakeBackend({ + ("multipass", "list", "--format", "json"): CommandResult( + args=[], returncode=0, + stdout=json.dumps({"list": []}), + stderr="", + ) +}) +client = MultipassClient(backend=backend) +vms = client.list() # no Multipass required +``` + +`FakeBackend` also supports queued responses for polling scenarios: + +```python +backend = FakeBackend() +backend.push("multipass", "info", "my-vm", "--format", "json", + result=CommandResult(..., stdout=info_no_ip)) +backend.push("multipass", "info", "my-vm", "--format", "json", + result=CommandResult(..., stdout=info_with_ip)) +``` + +--- + +## Running the tests + +```bash +# Setup +uv sync --extra dev + +# Unit tests (no Multipass required) +uv run pytest tests/unit/ -v + +# Integration tests (require Multipass installed and running) +uv run pytest -m integration -v +``` + +The integration test suite covers: full VM lifecycle, resources, soft delete + purge, `wait_for_ip`, `wait_ready` + SSH, suspend/resume, file transfer, snapshot/restore, clone, cloud-init, and error handling. + +--- + ## Contributing -To contribute, simply send a pull request. The tests will run. If they succeed, then I will be happy to merge your PR. +Send a pull request. Unit tests must pass; integration tests are welcome but not required in CI. From c657a94b7ff3a405b8da9166e714f64e56d1ea4e Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:50:40 +0200 Subject: [PATCH 13/24] feat: add cloud_init_config param and snap-safe temp file handling Adds cloud_init_config to launch() accepting a dict (serialized to JSON) or a raw YAML string. Temp files are written to Path.home() instead of /tmp so Multipass installed via snap can access them. File is deleted automatically after launch. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 67 +++++++++++++++++++++++++++++++++++++-- src/multipass/client.py | 21 +++++++++++- tests/unit/test_client.py | 48 ++++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5e16f56..190fd8c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary | Method | Description | |--------|-------------| -| `launch(name, image, *, cpus, memory, disk, cloud_init) → MultipassVM` | Launch a new VM | +| `launch(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Launch a new VM | | `get_vm(name) → MultipassVM` | Get a VM object by name | | `list() → list[VmInfo]` | List all VMs | | `find() → list[ImageInfo]` | List available images | @@ -141,8 +141,71 @@ vm.start() ### cloud-init +Pass a file path, a YAML string, or a dict — the SDK handles the rest. + +**File path** (you manage the file): + +```python +vm = client.launch(name="my-vm", cloud_init="/home/user/cloud-init.yaml") +``` + +**Inline dict** (serialized to JSON, which cloud-init accepts natively): + +```python +vm = client.launch( + name="my-vm", + cloud_init_config={ + "packages": ["git", "curl"], + "runcmd": ["apt-get upgrade -y"], + } +) +``` + +**Inline YAML string**: + +```python +vm = client.launch( + name="my-vm", + cloud_init_config=""" +#cloud-config +packages: + - git + - curl +runcmd: + - apt-get upgrade -y +""" +) +``` + +When `cloud_init_config` is used, the SDK writes a temporary file to the user's home directory (not `/tmp`) so that Multipass installed via snap can read it. The file is deleted automatically after launch. + +#### Custom user and SSH key + +```python +from pathlib import Path + +ssh_key = Path("~/.ssh/id_rsa.pub").expanduser().read_text().strip() + +vm = client.launch( + name="my-vm", + cloud_init_config={ + "users": [ + { + "name": "michele", + "groups": ["sudo"], + "shell": "/bin/bash", + "sudo": "ALL=(ALL) NOPASSWD:ALL", + "ssh_authorized_keys": [ssh_key], + } + ] + } +) +``` + +To keep the default `ubuntu` user alongside your custom one, add `"default"` as the first entry in the `users` list: + ```python -vm = client.launch(name="configured-vm", cloud_init="/path/to/cloud-init.yaml") +"users": ["default", {"name": "michele", ...}] ``` --- diff --git a/src/multipass/client.py b/src/multipass/client.py index 0a8d161..b9f0bc9 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import tempfile +from pathlib import Path from haikunator import Haikunator @@ -46,6 +48,7 @@ def launch( memory: str = "1G", disk: str = "5G", cloud_init: str | None = None, + cloud_init_config: dict | str | None = None, ) -> MultipassVM: if name is None: name = Haikunator().haikunate(token_length=0) @@ -54,7 +57,23 @@ def launch( cmd += ["--cloud-init", cloud_init] if image and image != "ubuntu-lts": cmd.append(image) - self._run(*cmd) + if cloud_init_config is not None: + content = ( + json.dumps(cloud_init_config, indent=2) + if isinstance(cloud_init_config, dict) + else cloud_init_config + ) + tmp = tempfile.NamedTemporaryFile( + dir=Path.home(), suffix=".yaml", delete=False, mode="w" + ) + try: + tmp.write(content) + tmp.close() + self._run(*cmd, "--cloud-init", tmp.name) + finally: + Path(tmp.name).unlink(missing_ok=True) + else: + self._run(*cmd) return MultipassVM(name, self._cmd, self._backend) def list(self) -> list[VmInfo]: diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 13edb45..acde0bc 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,4 +1,5 @@ import json +from pathlib import Path import pytest from multipass._backend import CommandResult, FakeBackend from multipass.client import MultipassClient @@ -101,10 +102,53 @@ def test_launch_with_cloud_init(): backend = FakeBackend() backend.set_default(make_ok()) client = MultipassClient(backend=backend) - client.launch(name="test-vm", cloud_init="/tmp/cloud-init.yaml") + client.launch(name="test-vm", cloud_init="/home/user/cloud-init.yaml") call = backend.last_call() assert "--cloud-init" in call - assert "/tmp/cloud-init.yaml" in call + assert "/home/user/cloud-init.yaml" in call + + +def test_launch_with_cloud_init_config_dict(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.client.Path.home", lambda: tmp_path) + + captured: dict = {} + + class CapturingBackend: + calls: list = [] + def run(self, args): + self.calls.append(list(args)) + if "--cloud-init" in args: + idx = args.index("--cloud-init") + captured["content"] = Path(args[idx + 1]).read_text() + captured["path"] = args[idx + 1] + return make_ok() + + client = MultipassClient(backend=CapturingBackend()) + client.launch(name="test-vm", cloud_init_config={"packages": ["git"]}) + assert "git" in captured["content"] + assert not Path(captured["path"]).exists() + + +def test_launch_with_cloud_init_config_str(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.client.Path.home", lambda: tmp_path) + + captured: dict = {} + + class CapturingBackend: + calls: list = [] + def run(self, args): + self.calls.append(list(args)) + if "--cloud-init" in args: + idx = args.index("--cloud-init") + captured["content"] = Path(args[idx + 1]).read_text() + captured["path"] = args[idx + 1] + return make_ok() + + yaml_str = "#cloud-config\npackages:\n - git\n" + client = MultipassClient(backend=CapturingBackend()) + client.launch(name="test-vm", cloud_init_config=yaml_str) + assert captured["content"] == yaml_str + assert not Path(captured["path"]).exists() def test_launch_raises_on_failure(): From 2ebbcaad5310c93ad927c7161d0e0ad4f73666ba Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:52:32 +0200 Subject: [PATCH 14/24] feat: add find_ssh_public_key() utility to utils module Adds a new find_ssh_public_key() helper that searches ~/.ssh/ for public keys in priority order (ed25519, rsa, ecdsa, dsa) and returns the stripped key string ready for cloud-init ssh_authorized_keys, or None if absent. Co-Authored-By: Claude Sonnet 4.6 --- src/multipass/__init__.py | 2 ++ src/multipass/utils.py | 20 +++++++++++++++ tests/unit/test_utils.py | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/multipass/utils.py create mode 100644 tests/unit/test_utils.py diff --git a/src/multipass/__init__.py b/src/multipass/__init__.py index 9a01e7b..bb903ae 100644 --- a/src/multipass/__init__.py +++ b/src/multipass/__init__.py @@ -20,6 +20,7 @@ SnapshotInfo, ) from ._backend import CommandResult, FakeBackend, SubprocessBackend +from .utils import find_ssh_public_key __all__ = [ "MultipassClient", @@ -42,4 +43,5 @@ "CommandResult", "FakeBackend", "SubprocessBackend", + "find_ssh_public_key", ] diff --git a/src/multipass/utils.py b/src/multipass/utils.py new file mode 100644 index 0000000..7f6dccf --- /dev/null +++ b/src/multipass/utils.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +_KEY_NAMES = ("id_ed25519", "id_rsa", "id_ecdsa", "id_dsa") + + +def find_ssh_public_key() -> str | None: + """Return the content of the first SSH public key found in ~/.ssh/, or None. + + Checks keys in priority order: ed25519, rsa, ecdsa, dsa. + Returns the stripped key string ready for use in cloud-init + ``ssh_authorized_keys``. + """ + ssh_dir = Path.home() / ".ssh" + for name in _KEY_NAMES: + pub = ssh_dir / f"{name}.pub" + if pub.exists(): + return pub.read_text(encoding="utf-8").strip() + return None diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..bbcda6d --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,53 @@ +from pathlib import Path +import pytest +from multipass.utils import find_ssh_public_key + + +def test_returns_ed25519_key_content(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + (ssh_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAA... user@host\n") + assert find_ssh_public_key() == "ssh-ed25519 AAAA... user@host" + + +def test_strips_trailing_whitespace(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + (ssh_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAA... \n\n") + assert find_ssh_public_key() == "ssh-ed25519 AAAA..." + + +def test_falls_back_to_rsa_when_ed25519_absent(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + (ssh_dir / "id_rsa.pub").write_text("ssh-rsa BBBB... user@host\n") + assert find_ssh_public_key() == "ssh-rsa BBBB... user@host" + + +def test_key_priority_order(tmp_path, monkeypatch): + """ed25519 is preferred over rsa when both exist.""" + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + (ssh_dir / "id_rsa.pub").write_text("ssh-rsa BBBB...\n") + (ssh_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAA...\n") + assert find_ssh_public_key() == "ssh-ed25519 AAAA..." + + +def test_returns_none_when_no_keys_present(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + (tmp_path / ".ssh").mkdir() + assert find_ssh_public_key() is None + + +def test_returns_none_when_ssh_dir_absent(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.utils.Path.home", lambda: tmp_path) + assert find_ssh_public_key() is None + + +def test_importable_from_top_level(): + from multipass import find_ssh_public_key as f + assert callable(f) From bf580e54125cf6a72b6c5a08c454f9641e6b76f0 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:57:39 +0200 Subject: [PATCH 15/24] feat: add MultipassClient.ensure_running() idempotent VM lifecycle method Co-Authored-By: Claude Sonnet 4.6 --- src/multipass/client.py | 49 ++++++++++++++- tests/unit/test_client.py | 123 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/multipass/client.py b/src/multipass/client.py index b9f0bc9..a5341dd 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -8,7 +8,7 @@ from ._backend import CommandBackend, CommandResult, SubprocessBackend from .exceptions import MultipassCommandError -from .models import AliasInfo, ImageInfo, NetworkInfo, VmInfo, VersionInfo +from .models import AliasInfo, ImageInfo, NetworkInfo, VmInfo, VmState, VersionInfo from .vm import MultipassVM @@ -76,6 +76,53 @@ def launch( self._run(*cmd) return MultipassVM(name, self._cmd, self._backend) + def ensure_running( + self, + name: str, + image: str | None = None, + *, + cpus: int = 1, + memory: str = "1G", + disk: str = "5G", + cloud_init: str | None = None, + cloud_init_config: dict | str | None = None, + ) -> MultipassVM: + """Ensure the named VM exists and is running. + + State machine: + - Not found → launch with provided parameters + - Deleted → purge all soft-deleted VMs, then launch + - Running → no-op + - Any other → start (Stopped, Suspended, etc.) + + Returns the ``MultipassVM`` object in all cases. + """ + from .exceptions import VmNotFoundError + + try: + info = self.get_vm(name).info() + except VmNotFoundError: + return self.launch( + name, image, + cpus=cpus, memory=memory, disk=disk, + cloud_init=cloud_init, cloud_init_config=cloud_init_config, + ) + + if info.state == VmState.RUNNING: + return self.get_vm(name) + + if info.state == VmState.DELETED: + self.purge() + return self.launch( + name, image, + cpus=cpus, memory=memory, disk=disk, + cloud_init=cloud_init, cloud_init_config=cloud_init_config, + ) + + # Stopped, Suspended, Starting, Restarting, Unknown → try to start + self.get_vm(name).start() + return self.get_vm(name) + def list(self) -> list[VmInfo]: return VmInfo.from_list_json(self._run_json("list", "--format", "json")) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index acde0bc..8cf2640 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -46,6 +46,23 @@ def make_err(stderr: str = "error") -> CommandResult: return CommandResult(args=[], returncode=1, stdout="", stderr=stderr) +def _info_json(name: str, state: str, ipv4: list[str] | None = None) -> str: + return json.dumps({ + "info": { + name: { + "state": state, + "ipv4": ipv4 or [], + "image_release": "22.04", + "image_hash": "", + "cpu_count": 1, + "memory": {}, + "disks": {}, + "mounts": {}, + } + } + }) + + def test_list_returns_vm_info_list(): backend = FakeBackend( {("multipass", "list", "--format", "json"): make_ok(LIST_JSON)} @@ -236,6 +253,112 @@ def test_custom_multipass_cmd(): assert backend.calls[0][0] == "/usr/local/bin/multipass" +# ------------------------------------------------------------------ ensure_running + +def test_ensure_running_launches_vm_when_not_found(): + name = "my-vm" + backend = FakeBackend() + backend.push("multipass", "info", name, "--format", "json", + result=make_err(f"instance '{name}' does not exist")) + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + + vm = client.ensure_running(name) + + assert vm.name == name + assert any(call[1] == "launch" for call in backend.calls) + + +def test_ensure_running_is_noop_when_already_running(): + name = "my-vm" + backend = FakeBackend({ + ("multipass", "info", name, "--format", "json"): make_ok(_info_json(name, "Running")), + }) + client = MultipassClient(backend=backend) + + vm = client.ensure_running(name) + + assert vm.name == name + assert not any(call[1] == "launch" for call in backend.calls) + assert not any(call[1] == "start" for call in backend.calls) + + +def test_ensure_running_starts_stopped_vm(): + name = "my-vm" + backend = FakeBackend({ + ("multipass", "info", name, "--format", "json"): make_ok(_info_json(name, "Stopped")), + ("multipass", "start", name): make_ok(), + }) + client = MultipassClient(backend=backend) + + vm = client.ensure_running(name) + + assert vm.name == name + assert any(call == ["multipass", "start", name] for call in backend.calls) + assert not any(call[1] == "launch" for call in backend.calls) + + +def test_ensure_running_starts_suspended_vm(): + name = "my-vm" + backend = FakeBackend({ + ("multipass", "info", name, "--format", "json"): make_ok(_info_json(name, "Suspended")), + ("multipass", "start", name): make_ok(), + }) + client = MultipassClient(backend=backend) + + vm = client.ensure_running(name) + + assert vm.name == name + assert any(call == ["multipass", "start", name] for call in backend.calls) + + +def test_ensure_running_purges_and_relaunches_deleted_vm(): + name = "my-vm" + backend = FakeBackend() + backend.push("multipass", "info", name, "--format", "json", + result=make_ok(_info_json(name, "Deleted"))) + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + + vm = client.ensure_running(name) + + assert vm.name == name + assert any(call == ["multipass", "purge"] for call in backend.calls) + assert any(call[1] == "launch" for call in backend.calls) + + +def test_ensure_running_forwards_launch_params(): + name = "my-vm" + backend = FakeBackend() + backend.push("multipass", "info", name, "--format", "json", + result=make_err(f"instance '{name}' does not exist")) + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + + client.ensure_running(name, "22.04", cpus=4, memory="8G", disk="30G") + + launch_call = next(call for call in backend.calls if call[1] == "launch") + assert "4" in launch_call + assert "8G" in launch_call + assert "30G" in launch_call + assert "22.04" in launch_call + + +def test_ensure_running_forwards_cloud_init_config(tmp_path, monkeypatch): + monkeypatch.setattr("multipass.client.Path.home", lambda: tmp_path) + name = "my-vm" + backend = FakeBackend() + backend.push("multipass", "info", name, "--format", "json", + result=make_err(f"instance '{name}' does not exist")) + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + + client.ensure_running(name, cloud_init_config={"ssh_authorized_keys": ["ssh-ed25519 AAAA..."]}) + + launch_call = next(call for call in backend.calls if call[1] == "launch") + assert "--cloud-init" in launch_call + + def test_public_api_importable(): from multipass import ( MultipassClient, From cebdc5773ffb8c3c43f8c54347b2bbd6262be0ba Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:00:59 +0200 Subject: [PATCH 16/24] refactor: move VmNotFoundError import to module level in client.py --- src/multipass/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/multipass/client.py b/src/multipass/client.py index a5341dd..c36b696 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -7,7 +7,7 @@ from haikunator import Haikunator from ._backend import CommandBackend, CommandResult, SubprocessBackend -from .exceptions import MultipassCommandError +from .exceptions import MultipassCommandError, VmNotFoundError from .models import AliasInfo, ImageInfo, NetworkInfo, VmInfo, VmState, VersionInfo from .vm import MultipassVM @@ -97,8 +97,6 @@ def ensure_running( Returns the ``MultipassVM`` object in all cases. """ - from .exceptions import VmNotFoundError - try: info = self.get_vm(name).info() except VmNotFoundError: From b6adfdfff1c11da86249f596814dbce9e94c3af7 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:03:18 +0200 Subject: [PATCH 17/24] chore: bump to v0.4.0, document ensure_running and find_ssh_public_key --- README.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 190fd8c..9c6bfab 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary | Method | Description | |--------|-------------| | `launch(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Launch a new VM | +| `ensure_running(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Idempotent: launch, start, or no-op so the VM ends up Running | | `get_vm(name) → MultipassVM` | Get a VM object by name | | `list() → list[VmInfo]` | List all VMs | | `find() → list[ImageInfo]` | List available images | @@ -208,6 +209,25 @@ To keep the default `ubuntu` user alongside your custom one, add `"default"` as "users": ["default", {"name": "michele", ...}] ``` +### SSH key injection helper + +`find_ssh_public_key()` returns the content of the first SSH public key found in +`~/.ssh/` (priority: ed25519 → rsa → ecdsa → dsa), or `None` if none exists. +Combine it with `cloud_init_config` to make new VMs immediately accessible: + +```python +from multipass import MultipassClient, find_ssh_public_key + +client = MultipassClient() +pub_key = find_ssh_public_key() + +vm = client.ensure_running( + "my-vm", + cloud_init_config={"ssh_authorized_keys": [pub_key]} if pub_key else None, +) +ip = vm.wait_ready(timeout=180) +``` + --- ## Exceptions diff --git a/pyproject.toml b/pyproject.toml index 3cfcba1..1e47ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "multipass-sdk" -version = "0.3.0" +version = "0.4.0" description = "Unofficial Python SDK for Canonical Multipass" readme = "README.md" license = { text = "MIT" } From e603372dc8fe4a376b0c4f1129918ea0b07adda9 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:11:51 +0200 Subject: [PATCH 18/24] docs: warn about system-wide purge in ensure_running docstring --- src/multipass/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/multipass/client.py b/src/multipass/client.py index c36b696..a9c795a 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -91,10 +91,16 @@ def ensure_running( State machine: - Not found → launch with provided parameters - - Deleted → purge all soft-deleted VMs, then launch + - Deleted → purge *all* soft-deleted VMs (system-wide), then launch - Running → no-op - Any other → start (Stopped, Suspended, etc.) + .. warning:: + When the VM is in DELETED state, ``multipass purge`` is called, which + permanently removes **all** soft-deleted instances on the system — not + only the target VM. Ensure no other instances in DELETED state need + to be recovered before calling this method. + Returns the ``MultipassVM`` object in all cases. """ try: From 699d3600788d4bc2cb6bf66ccd54dc7aed89fdec Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:19:40 +0200 Subject: [PATCH 19/24] fix: serialize cloud_init_config dict as YAML with #cloud-config header cloud-init requires the user-data file to start with '#cloud-config' to process it as cloud-config. Using json.dumps() produced JSON without that header, so cloud-init silently ignored the file and SSH keys were never injected into the VM's authorized_keys. Add pyyaml>=6.0 as a dependency and use yaml.dump() to produce valid cloud-config YAML. Update the test to assert the header is present and the serialized content parses back correctly. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + src/multipass/client.py | 11 ++++++----- tests/unit/test_client.py | 6 +++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e47ebb..2dce644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ license = { text = "MIT" } requires-python = ">=3.11" dependencies = [ "haikunator>=2.1.0", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/src/multipass/client.py b/src/multipass/client.py index a9c795a..7b02ebd 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -4,6 +4,8 @@ import tempfile from pathlib import Path +import yaml + from haikunator import Haikunator from ._backend import CommandBackend, CommandResult, SubprocessBackend @@ -58,11 +60,10 @@ def launch( if image and image != "ubuntu-lts": cmd.append(image) if cloud_init_config is not None: - content = ( - json.dumps(cloud_init_config, indent=2) - if isinstance(cloud_init_config, dict) - else cloud_init_config - ) + if isinstance(cloud_init_config, dict): + content = "#cloud-config\n" + yaml.dump(cloud_init_config, default_flow_style=False) + else: + content = cloud_init_config tmp = tempfile.NamedTemporaryFile( dir=Path.home(), suffix=".yaml", delete=False, mode="w" ) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 8cf2640..b4bf84a 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -142,7 +142,11 @@ def run(self, args): client = MultipassClient(backend=CapturingBackend()) client.launch(name="test-vm", cloud_init_config={"packages": ["git"]}) - assert "git" in captured["content"] + content = captured["content"] + assert content.startswith("#cloud-config\n") + import yaml + parsed = yaml.safe_load(content) + assert parsed["packages"] == ["git"] assert not Path(captured["path"]).exists() From 83b3704b2c37e50c09773cd97073f691cd4d3faa Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:05:31 +0200 Subject: [PATCH 20/24] feat: add MultipassVM.exec_structured() to confine bash boundary in SDK Adds exec_structured(argv, *, env, cwd) to MultipassVM. The method builds the bash prologue (cd + export) from structured parameters internally, so callers above the SDK never need to construct shell strings. Bumps version to 0.5.0. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- src/multipass/vm.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2dce644..31eaaa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "multipass-sdk" -version = "0.4.0" +version = "0.5.0" description = "Unofficial Python SDK for Canonical Multipass" readme = "README.md" license = { text = "MIT" } diff --git a/src/multipass/vm.py b/src/multipass/vm.py index 872eeac..4970591 100644 --- a/src/multipass/vm.py +++ b/src/multipass/vm.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shlex import socket import time @@ -65,6 +66,28 @@ def exec(self, command: list[str]) -> CommandResult: """Execute a command in the VM. command must be a list of args (no shell splitting).""" return self._run([self._cmd, "exec", self.name, "--"] + command) + def exec_structured( + self, + argv: list[str], + *, + env: dict[str, str] | None = None, + cwd: str | None = None, + ) -> CommandResult: + """Execute a structured command inside the VM via a login shell. + + Builds the minimal bash prologue (cd + export) from structured arguments + so callers never need to construct shell strings. The bash boundary is + confined to this single method. + """ + parts: list[str] = [] + if cwd: + parts.append(f"cd {shlex.quote(cwd)}") + for k, v in (env or {}).items(): + parts.append(f"export {k}={shlex.quote(v)}") + parts.append(shlex.join(argv)) + command = " && ".join(parts) + return self._run([self._cmd, "exec", self.name, "--", "bash", "-lc", command]) + def transfer(self, source: str, dest: str) -> None: """Transfer files between host and VM. From 54f2ac8ee03aa4d2aeeaeb805000030367270e72 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Mon, 18 May 2026 15:50:33 +0200 Subject: [PATCH 21/24] feat: add VmConfig, launch_many, CloudInitConfig, e2e, and align with sibling SDKs - Add VmConfig dataclass for reusable launch configuration (mirrors azure-vm-sdk, proxmox-sdk) - Add CloudInitConfig dataclass with to_dict() serialization - Add MultipassClient.launch_many() with parallel launch and rollback - MultipassClient.launch() now accepts VmConfig or inline params - Add e2e.py CLI script (multipass-vm-e2e) for lifecycle testing - Add testing.py module re-exporting FakeBackend - Enhance CommandBackend protocol and FakeBackend with cwd/env support - Fix exec_structured() to include set -e for shell safety - Fix snapshots() to filter by current VM - Bump version to 0.6.0 Co-Authored-By: Claude Opus 4.7 --- pyproject.toml | 5 +- src/multipass/__init__.py | 4 + src/multipass/_backend.py | 44 ++++++- src/multipass/client.py | 66 +++++++++- src/multipass/e2e.py | 238 +++++++++++++++++++++++++++++++++++++ src/multipass/models.py | 42 ++++++- src/multipass/testing.py | 5 + src/multipass/vm.py | 5 +- tests/unit/test_backend.py | 28 +++++ tests/unit/test_client.py | 77 +++++++++++- tests/unit/test_models.py | 44 ++++++- tests/unit/test_vm.py | 42 +++++++ 12 files changed, 587 insertions(+), 13 deletions(-) create mode 100644 src/multipass/e2e.py create mode 100644 src/multipass/testing.py diff --git a/pyproject.toml b/pyproject.toml index 31eaaa9..47e43db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "multipass-sdk" -version = "0.5.0" +version = "0.6.0" description = "Unofficial Python SDK for Canonical Multipass" readme = "README.md" license = { text = "MIT" } @@ -16,6 +16,9 @@ dev = [ "pytest-cov>=5.0", ] +[project.scripts] +multipass-vm-e2e = "multipass.e2e:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/multipass/__init__.py b/src/multipass/__init__.py index bb903ae..38b0136 100644 --- a/src/multipass/__init__.py +++ b/src/multipass/__init__.py @@ -11,6 +11,8 @@ VmAlreadySuspendedError, ) from .models import ( + CloudInitConfig, + VmConfig, VmInfo, VmState, ImageInfo, @@ -33,6 +35,8 @@ "VmAlreadyRunningError", "VmNotRunningError", "VmAlreadySuspendedError", + "CloudInitConfig", + "VmConfig", "VmInfo", "VmState", "ImageInfo", diff --git a/src/multipass/_backend.py b/src/multipass/_backend.py index 76c13ef..2190cdc 100644 --- a/src/multipass/_backend.py +++ b/src/multipass/_backend.py @@ -20,15 +20,27 @@ def success(self) -> bool: class CommandBackend(Protocol): - def run(self, args: list[str]) -> CommandResult: ... + def run( + self, + args: list[str], + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> CommandResult: ... class SubprocessBackend: """Real backend — invokes the Multipass CLI via subprocess.""" - def run(self, args: list[str]) -> CommandResult: + def run( + self, + args: list[str], + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> CommandResult: try: - proc = subprocess.run(args, capture_output=True, text=True) + proc = subprocess.run(args, capture_output=True, text=True, cwd=cwd, env=env) except FileNotFoundError: raise MultipassNotInstalledError() return CommandResult( @@ -49,6 +61,8 @@ def __init__( self._responses: dict[tuple[str, ...], CommandResult] = responses or {} self._queues: dict[tuple[str, ...], list[CommandResult]] = {} self._calls: list[list[str]] = [] + self._cwds: list[str | None] = [] + self._envs: list[dict[str, str] | None] = [] self._default: CommandResult | None = None def set_default(self, result: CommandResult) -> None: @@ -58,8 +72,16 @@ def push(self, *args: str, result: CommandResult) -> None: """Queue a response for args (consumed in order, takes priority over responses/default).""" self._queues.setdefault(args, []).append(result) - def run(self, args: list[str]) -> CommandResult: + def run( + self, + args: list[str], + *, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> CommandResult: self._calls.append(list(args)) + self._cwds.append(cwd) + self._envs.append(env) key = tuple(args) if key in self._queues and self._queues[key]: return self._queues[key].pop(0) @@ -73,5 +95,19 @@ def run(self, args: list[str]) -> CommandResult: def calls(self) -> list[list[str]]: return list(self._calls) + @property + def cwds(self) -> list[str | None]: + return list(self._cwds) + + @property + def envs(self) -> list[dict[str, str] | None]: + return list(self._envs) + def last_call(self) -> list[str]: return self._calls[-1] if self._calls else [] + + def last_cwd(self) -> str | None: + return self._cwds[-1] if self._cwds else None + + def last_env(self) -> dict[str, str] | None: + return self._envs[-1] if self._envs else None diff --git a/src/multipass/client.py b/src/multipass/client.py index 7b02ebd..ed43807 100644 --- a/src/multipass/client.py +++ b/src/multipass/client.py @@ -2,6 +2,7 @@ import json import tempfile +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import yaml @@ -10,7 +11,7 @@ from ._backend import CommandBackend, CommandResult, SubprocessBackend from .exceptions import MultipassCommandError, VmNotFoundError -from .models import AliasInfo, ImageInfo, NetworkInfo, VmInfo, VmState, VersionInfo +from .models import AliasInfo, ImageInfo, NetworkInfo, VmConfig, VmInfo, VmState, VersionInfo from .vm import MultipassVM @@ -43,7 +44,7 @@ def get_vm(self, name: str) -> MultipassVM: def launch( self, - name: str | None = None, + name: str | VmConfig | None = None, image: str | None = None, *, cpus: int = 1, @@ -52,6 +53,23 @@ def launch( cloud_init: str | None = None, cloud_init_config: dict | str | None = None, ) -> MultipassVM: + """Launch a new VM. + + Accepts either a VmConfig object or inline parameters:: + + vm = client.launch("my-vm", cpus=4, memory="8G") + vm = client.launch(VmConfig(name="my-vm", cpus=4, memory="8G")) + """ + if isinstance(name, VmConfig): + cfg = name + name = cfg.name + image = cfg.image + cpus = cfg.cpus + memory = cfg.memory + disk = cfg.disk + cloud_init = cfg.cloud_init + cloud_init_config = cfg.cloud_init_config + if name is None: name = Haikunator().haikunate(token_length=0) cmd = ["launch", "-n", name, "-c", str(cpus), "-m", memory, "-d", disk] @@ -77,6 +95,48 @@ def launch( self._run(*cmd) return MultipassVM(name, self._cmd, self._backend) + def launch_many( + self, + configs: list[VmConfig], + *, + max_workers: int | None = None, + ) -> list[MultipassVM]: + """Launch multiple VMs in parallel. Rolls back all on any failure.""" + if not configs: + return [] + + workers = max_workers if max_workers is not None else len(configs) + created: list[MultipassVM] = [] + first_error: BaseException | None = None + + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = { + executor.submit(self.launch, cfg): cfg + for cfg in configs + } + + for fut in as_completed(futures): + if first_error is not None: + continue + exc = fut.exception() + if exc is not None: + first_error = exc + for pending in futures: + pending.cancel() + else: + created.append(fut.result()) + + if first_error is not None: + with ThreadPoolExecutor(max_workers=max(len(created), 1)) as rollback: + for rf in [rollback.submit(vm.delete) for vm in created]: + try: + rf.result() + except Exception: + pass + raise first_error + + return created + def ensure_running( self, name: str, @@ -124,7 +184,7 @@ def ensure_running( cloud_init=cloud_init, cloud_init_config=cloud_init_config, ) - # Stopped, Suspended, Starting, Restarting, Unknown → try to start + # Stopped, Suspended, Starting, Restarting, Unknown self.get_vm(name).start() return self.get_vm(name) diff --git a/src/multipass/e2e.py b/src/multipass/e2e.py new file mode 100644 index 0000000..bf24e41 --- /dev/null +++ b/src/multipass/e2e.py @@ -0,0 +1,238 @@ +"""End-to-end verification: create VM(s), verify readiness, execute a command, +delete the VM(s). + +Prerequisites: + Multipass installed and running + +Usage: + uv run multipass-vm-e2e + uv run multipass-vm-e2e --name my-test-vm --cpus 2 --memory 4G --disk 10G + uv run multipass-vm-e2e --count 3 + uv run multipass-vm-e2e --count 2 --name worker --cpus 1 + uv run multipass-vm-e2e --configs '[{"name":"web","cpus":2},{"name":"db","cpus":4}]' + uv run multipass-vm-e2e --list-images +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import fields + +from .client import MultipassClient +from .exceptions import MultipassError +from .models import VmConfig +from .vm import MultipassVM + + +def _plural_label(names: list[str], actual_count: int | None = None) -> str: + """Return 'VM 'name'' for a single VM, or 'N VMs' for multiple.""" + count = actual_count if actual_count is not None else len(names) + if count == 1: + return f"VM '{names[0]}'" + return f"{count} VMs" + + +def _verify_vm(vm: MultipassVM, idx: int, total: int, timeout: float) -> int: + """Run SSH/exec checks on a single VM. Returns 0 on success, 1 on failure.""" + label = f" [{idx}/{total}] {vm.name}" + + print(f"{label}: waiting for SSH ...") + t0 = time.monotonic() + try: + ip = vm.wait_ready(timeout=timeout, port=22) + except MultipassError as exc: + print(f"{label}: SSH timeout — {exc}", file=sys.stderr) + return 1 + print(f"{label}: got IP {ip}, SSH ready in {time.monotonic() - t0:.1f}s") + + print(f"{label}: running verification command ...") + result = vm.exec(["uname", "-a"]) + print(f"{label}: exit={result.returncode} stdout={result.stdout.strip()}") + if result.stderr: + print(f"{label}: stderr={result.stderr.strip()}") + if not result.success: + print(f"{label}: FAILED — non-zero exit code", file=sys.stderr) + return 1 + + info = vm.info() + print( + f"{label}: state={info.state.value} image={info.image} " + f"cpus={info.cpus} memory={info.memory_total}" + ) + return 0 + + +def _check_mutually_exclusive(args: argparse.Namespace) -> None: + if not args.configs: + return + conflicts = [] + if args.count != 1: + conflicts.append("--count") + if args.name is not None: + conflicts.append("--name") + if args.cpus != 1: + conflicts.append("--cpus") + if args.memory != "1G": + conflicts.append("--memory") + if args.disk != "5G": + conflicts.append("--disk") + if args.image is not None: + conflicts.append("--image") + if conflicts: + raise SystemExit( + f"--configs is mutually exclusive with: {', '.join(conflicts)}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="End-to-end VM lifecycle test (create, verify, delete)." + ) + parser.add_argument( + "--name", default=None, + help="VM name; used as prefix when --count > 1 (auto-generated if omitted).", + ) + parser.add_argument( + "--cpus", type=int, default=1, + help="Number of CPUs (default: 1).", + ) + parser.add_argument( + "--memory", default="1G", + help="Memory size (default: 1G).", + ) + parser.add_argument( + "--disk", default="5G", + help="Disk size (default: 5G).", + ) + parser.add_argument( + "--image", default=None, + help="Ubuntu image (default: latest LTS).", + ) + parser.add_argument( + "--timeout", type=float, default=300, + help="Max seconds to wait for SSH readiness (default: 300).", + ) + parser.add_argument( + "--count", type=int, default=1, + help="Number of identical VMs to create in parallel (default: 1).", + ) + parser.add_argument( + "--configs", + help=( + "JSON array of VmConfig objects, e.g. " + "'[{\"name\":\"web\",\"cpus\":2},{\"name\":\"db\",\"cpus\":4}]'. " + "Mutually exclusive with --name/--cpus/--memory/--disk/--image/--count." + ), + ) + parser.add_argument( + "--list-images", action="store_true", + help="List available images and exit.", + ) + args = parser.parse_args() + + _check_mutually_exclusive(args) + + if args.count < 1: + raise SystemExit("--count must be at least 1") + + client = MultipassClient() + + if args.list_images: + images = client.find() + print(f"{'Aliases':<30} {'OS':<12} {'Release':<16} {'Version':<12}") + print("-" * 70) + for img in images: + print(f"{','.join(img.aliases):<30} {img.os:<12} {img.release:<16} {img.version:<12}") + raise SystemExit(0) + + if args.configs: + try: + raw = json.loads(args.configs) + except json.JSONDecodeError as exc: + raise SystemExit(f"--configs: invalid JSON — {exc}") from exc + if not isinstance(raw, list) or not raw: + raise SystemExit("--configs: expected a non-empty JSON array") + valid_fields = {f.name for f in fields(VmConfig)} + configs = [] + for i, item in enumerate(raw): + unknown = set(item) - valid_fields + if unknown: + raise SystemExit(f"--configs[{i}]: unknown fields {sorted(unknown)}") + configs.append(VmConfig(**item)) + else: + prefix = args.name or f"e2e-{int(time.time())}" + if args.count == 1: + configs = [VmConfig( + name=prefix, image=args.image, + cpus=args.cpus, memory=args.memory, disk=args.disk, + )] + else: + configs = [ + VmConfig( + name=f"{prefix}-{i}", image=args.image, + cpus=args.cpus, memory=args.memory, disk=args.disk, + ) + for i in range(args.count) + ] + + names = [cfg.name for cfg in configs] + num_vms = len(configs) + vms: list[MultipassVM] = [] + exit_code = 0 + + try: + if num_vms == 1: + cfg = configs[0] + print(f"[1/3] Launching VM '{names[0]}' (cpus={cfg.cpus}, memory={cfg.memory}, disk={cfg.disk}) ...") + else: + cfg0 = configs[0] + print(f"[1/3] Launching {num_vms} VMs in parallel (cpus={cfg0.cpus}, memory={cfg0.memory}, disk={cfg0.disk}) ...") + for name in names: + print(f" - {name}") + t0 = time.monotonic() + vms = client.launch_many(configs) + dt = time.monotonic() - t0 + verb = "launch" if num_vms == 1 else f"all {num_vms} launches" + print(f" {verb} completed in {dt:.1f}s") + + print(f"[2/3] Verifying {num_vms} VM(s) ...") + for i, vm in enumerate(vms, start=1): + rc = _verify_vm(vm, i, num_vms, args.timeout) + if rc != 0: + exit_code = 1 + + except MultipassError as exc: + print(f"\nERROR: {exc}", file=sys.stderr) + exit_code = 2 + except KeyboardInterrupt: + print("\nInterrupted.", file=sys.stderr) + exit_code = 130 + + if vms: + label = _plural_label(names, len(vms)) + print(f"[3/3] Deleting {label} ...") + try: + for vm in vms: + vm.delete(purge=True) + print(" done.") + except MultipassError as exc: + print(f" cleanup failed: {exc}", file=sys.stderr) + exit_code = 3 + elif exit_code != 130: + print("[3/3] No VMs to clean up.") + + print() + label = _plural_label(names, len(vms)) + if exit_code == 0: + print(f"SUCCESS — {label} completed full lifecycle.") + else: + print(f"FAILURE (exit code {exit_code}) — see errors above.") + + raise SystemExit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/src/multipass/models.py b/src/multipass/models.py index 009d254..31f5537 100644 --- a/src/multipass/models.py +++ b/src/multipass/models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum @@ -148,6 +148,46 @@ def from_aliases_json(cls, data: dict) -> list["AliasInfo"]: ] +@dataclass +class VmConfig: + """Reusable VM launch configuration.""" + + name: str | None = None + image: str | None = None + cpus: int = 1 + memory: str = "1G" + disk: str = "5G" + cloud_init: str | None = None + cloud_init_config: dict | str | None = None + + +@dataclass +class CloudInitConfig: + """Structured cloud-init configuration. + + Example:: + + CloudInitConfig( + packages=["git", "curl"], + ssh_authorized_keys=["ssh-ed25519 AAAA..."], + ) + """ + + packages: list[str] | None = None + ssh_authorized_keys: list[str] | None = None + runcmd: list[list[str]] | None = None + write_files: list[dict] | None = None + users: list[dict] | None = None + + def to_dict(self) -> dict: + result: dict = {} + for f in fields(self): + value = getattr(self, f.name) + if value is not None: + result[f.name] = value + return result + + @dataclass class SnapshotInfo: name: str diff --git a/src/multipass/testing.py b/src/multipass/testing.py new file mode 100644 index 0000000..164520e --- /dev/null +++ b/src/multipass/testing.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._backend import FakeBackend + +__all__ = ["FakeBackend"] diff --git a/src/multipass/vm.py b/src/multipass/vm.py index 4970591..c594503 100644 --- a/src/multipass/vm.py +++ b/src/multipass/vm.py @@ -85,7 +85,7 @@ def exec_structured( for k, v in (env or {}).items(): parts.append(f"export {k}={shlex.quote(v)}") parts.append(shlex.join(argv)) - command = " && ".join(parts) + command = "set -e && " + " && ".join(parts) return self._run([self._cmd, "exec", self.name, "--", "bash", "-lc", command]) def transfer(self, source: str, dest: str) -> None: @@ -119,7 +119,8 @@ def unmount(self, mount: str) -> None: def snapshots(self) -> list[SnapshotInfo]: result = self._run([self._cmd, "list", "--snapshots", "--format", "json"]) - return SnapshotInfo.from_snapshots_json(json.loads(result.stdout)) + all_snaps = SnapshotInfo.from_snapshots_json(json.loads(result.stdout)) + return [s for s in all_snaps if s.instance == self.name] def snapshot(self, name: str, *, comment: str | None = None) -> SnapshotInfo: cmd = [self._cmd, "snapshot", self.name, "--name", name] diff --git a/tests/unit/test_backend.py b/tests/unit/test_backend.py index 4dc5485..c189190 100644 --- a/tests/unit/test_backend.py +++ b/tests/unit/test_backend.py @@ -50,3 +50,31 @@ def test_fake_backend_last_call(): backend.set_default(ok) backend.run(["multipass", "start", "vm1"]) assert backend.last_call() == ["multipass", "start", "vm1"] + + +def test_fake_backend_tracks_cwd(): + backend = FakeBackend() + ok = CommandResult(args=[], returncode=0, stdout="", stderr="") + backend.set_default(ok) + backend.run(["multipass", "list"], cwd="/some/dir") + assert backend.last_cwd() == "/some/dir" + assert backend.cwds == ["/some/dir"] + + +def test_fake_backend_tracks_env(): + backend = FakeBackend() + ok = CommandResult(args=[], returncode=0, stdout="", stderr="") + backend.set_default(ok) + env = {"KEY": "value"} + backend.run(["multipass", "list"], env=env) + assert backend.last_env() == env + assert backend.envs == [env] + + +def test_fake_backend_default_no_cwd_env(): + backend = FakeBackend() + ok = CommandResult(args=[], returncode=0, stdout="", stderr="") + backend.set_default(ok) + backend.run(["multipass", "list"]) + assert backend.last_cwd() is None + assert backend.last_env() is None diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b4bf84a..72c9f59 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -4,7 +4,7 @@ from multipass._backend import CommandResult, FakeBackend from multipass.client import MultipassClient from multipass.exceptions import MultipassCommandError -from multipass.models import VmState +from multipass.models import VmConfig, VmState from multipass.vm import MultipassVM LIST_JSON = json.dumps({ @@ -374,6 +374,8 @@ def test_public_api_importable(): VmAlreadyRunningError, VmNotRunningError, VmAlreadySuspendedError, + VmConfig, + CloudInitConfig, VmInfo, VmState, ImageInfo, @@ -386,3 +388,76 @@ def test_public_api_importable(): SubprocessBackend, ) assert MultipassClient is not None + assert VmConfig is not None + assert CloudInitConfig is not None + + +# ------------------------------------------------------------------ VmConfig launch + + +def test_launch_with_vm_config(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + cfg = VmConfig(name="cfg-vm", cpus=8, memory="16G", disk="100G") + vm = client.launch(cfg) + assert vm.name == "cfg-vm" + call = backend.last_call() + assert "cfg-vm" in call + assert "8" in call + assert "16G" in call + assert "100G" in call + + +def test_launch_with_vm_config_generates_name(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + vm = client.launch(VmConfig(cpus=2)) + assert vm.name # auto-generated + + +# ------------------------------------------------------------------ launch_many + + +def test_launch_many_returns_vms(): + backend = FakeBackend() + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + configs = [ + VmConfig(name="vm1", cpus=1), + VmConfig(name="vm2", cpus=2), + ] + vms = client.launch_many(configs) + assert len(vms) == 2 + assert vms[0].name == "vm1" + assert vms[1].name == "vm2" + + +def test_launch_many_empty_returns_empty(): + client = MultipassClient(backend=FakeBackend()) + assert client.launch_many([]) == [] + + +def test_launch_many_rolls_back_on_failure(): + backend = FakeBackend() + # First launch succeeds, second fails + backend.push( + "multipass", "launch", "-n", "vm1", "-c", "1", "-m", "1G", "-d", "5G", + result=make_ok(), + ) + backend.push( + "multipass", "launch", "-n", "vm2", "-c", "2", "-m", "1G", "-d", "5G", + result=make_err("launch failed"), + ) + # vm1 delete during rollback + backend.set_default(make_ok()) + client = MultipassClient(backend=backend) + configs = [ + VmConfig(name="vm1", cpus=1), + VmConfig(name="vm2", cpus=2), + ] + with pytest.raises(MultipassCommandError): + client.launch_many(configs) + # Verify vm1 was deleted (rollback) + assert any(call[1] == "delete" for call in backend.calls) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index c4acdbc..00145d2 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,5 +1,5 @@ from multipass.models import ( - VmInfo, VmState, ImageInfo, NetworkInfo, VersionInfo, AliasInfo, SnapshotInfo + CloudInitConfig, VmConfig, VmInfo, VmState, ImageInfo, NetworkInfo, VersionInfo, AliasInfo, SnapshotInfo ) INFO_JSON = { @@ -112,3 +112,45 @@ def test_snapshot_info_from_json(): assert snaps[0].name == "snapshot1" assert snaps[0].instance == "my-vm" assert snaps[0].parent is None + + +def test_vm_config_defaults(): + cfg = VmConfig() + assert cfg.name is None + assert cfg.cpus == 1 + assert cfg.memory == "1G" + assert cfg.disk == "5G" + + +def test_vm_config_with_values(): + cfg = VmConfig(name="test", cpus=4, memory="8G", disk="30G", image="22.04") + assert cfg.name == "test" + assert cfg.cpus == 4 + assert cfg.memory == "8G" + assert cfg.disk == "30G" + assert cfg.image == "22.04" + + +def test_cloud_init_config_to_dict_includes_only_set_fields(): + cfg = CloudInitConfig(packages=["git", "curl"]) + d = cfg.to_dict() + assert d == {"packages": ["git", "curl"]} + + +def test_cloud_init_config_to_dict_excludes_none(): + cfg = CloudInitConfig( + packages=["git"], + ssh_authorized_keys=["ssh-ed25519 AAAA"], + runcmd=[["echo", "hello"]], + ) + d = cfg.to_dict() + assert "packages" in d + assert "ssh_authorized_keys" in d + assert "runcmd" in d + assert "write_files" not in d + assert "users" not in d + + +def test_cloud_init_config_to_dict_empty(): + cfg = CloudInitConfig() + assert cfg.to_dict() == {} diff --git a/tests/unit/test_vm.py b/tests/unit/test_vm.py index 8b5f7ab..fc54236 100644 --- a/tests/unit/test_vm.py +++ b/tests/unit/test_vm.py @@ -353,3 +353,45 @@ def test_wait_ready_waits_for_ip_before_tcp(mock_conn, mock_sleep): ip = vm.wait_ready(timeout=120, port=22) assert ip == "192.168.64.5" mock_conn.assert_called_once() # TCP check solo quando IP disponibile + + +def test_exec_structured_includes_set_e(): + backend = FakeBackend() + backend.set_default(make_ok()) + vm = MultipassVM("my-vm", "multipass", backend) + vm.exec_structured(["echo", "hello"], cwd="/tmp", env={"FOO": "bar"}) + call = backend.last_call() + # The bash command should start with "set -e" + bash_cmd = call[-1] + assert bash_cmd.startswith("set -e &&") + + +def test_snapshots_filters_by_current_vm(): + import json + snapshots_for_two = json.dumps({ + "errors": [], + "info": { + "my-vm": { + "snap1": { + "comment": "First", + "created": "2023-01-01T00:00:00Z", + "parent": "", + } + }, + "other-vm": { + "snap2": { + "comment": "Other", + "created": "2023-01-02T00:00:00Z", + "parent": "", + } + }, + } + }) + backend = FakeBackend({ + ("multipass", "list", "--snapshots", "--format", "json"): make_ok(snapshots_for_two) + }) + vm = MultipassVM("my-vm", "multipass", backend) + snaps = vm.snapshots() + assert len(snaps) == 1 + assert snaps[0].name == "snap1" + assert snaps[0].instance == "my-vm" From 308a4d7f6eacd22d79d549151cf83b87ed011423 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Mon, 18 May 2026 17:57:55 +0200 Subject: [PATCH 22/24] feat: extend e2e with feature tests for all major VM operations Add feature tests: exec_structured, stop/start, restart, transfer, clone, snapshot/restore. Each is individually skippable via --skip. Feature tests only run on single-VM mode (--count 1) by default. Co-Authored-By: Claude Opus 4.7 --- src/multipass/e2e.py | 347 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 303 insertions(+), 44 deletions(-) diff --git a/src/multipass/e2e.py b/src/multipass/e2e.py index bf24e41..44d2afe 100644 --- a/src/multipass/e2e.py +++ b/src/multipass/e2e.py @@ -1,4 +1,4 @@ -"""End-to-end verification: create VM(s), verify readiness, execute a command, +"""End-to-end verification: create VM(s), verify readiness, run feature tests, delete the VM(s). Prerequisites: @@ -11,6 +11,7 @@ uv run multipass-vm-e2e --count 2 --name worker --cpus 1 uv run multipass-vm-e2e --configs '[{"name":"web","cpus":2},{"name":"db","cpus":4}]' uv run multipass-vm-e2e --list-images + uv run multipass-vm-e2e --skip transfer,clone """ from __future__ import annotations @@ -18,17 +19,20 @@ import argparse import json import sys +import tempfile import time from dataclasses import fields +from pathlib import Path from .client import MultipassClient from .exceptions import MultipassError -from .models import VmConfig +from .models import VmConfig, VmState from .vm import MultipassVM +_STEPS = 5 + def _plural_label(names: list[str], actual_count: int | None = None) -> str: - """Return 'VM 'name'' for a single VM, or 'N VMs' for multiple.""" count = actual_count if actual_count is not None else len(names) if count == 1: return f"VM '{names[0]}'" @@ -36,7 +40,7 @@ def _plural_label(names: list[str], actual_count: int | None = None) -> str: def _verify_vm(vm: MultipassVM, idx: int, total: int, timeout: float) -> int: - """Run SSH/exec checks on a single VM. Returns 0 on success, 1 on failure.""" + """Run SSH/exec checks. Returns 0 on success, 1 on failure.""" label = f" [{idx}/{total}] {vm.name}" print(f"{label}: waiting for SSH ...") @@ -56,12 +60,6 @@ def _verify_vm(vm: MultipassVM, idx: int, total: int, timeout: float) -> int: if not result.success: print(f"{label}: FAILED — non-zero exit code", file=sys.stderr) return 1 - - info = vm.info() - print( - f"{label}: state={info.state.value} image={info.image} " - f"cpus={info.cpus} memory={info.memory_total}" - ) return 0 @@ -82,35 +80,235 @@ def _check_mutually_exclusive(args: argparse.Namespace) -> None: if args.image is not None: conflicts.append("--image") if conflicts: - raise SystemExit( - f"--configs is mutually exclusive with: {', '.join(conflicts)}" + raise SystemExit(f"--configs is mutually exclusive with: {', '.join(conflicts)}") + + +# ------------------------------------------------------------------ feature tests + + +def _test_exec_structured(vm: MultipassVM) -> bool: + """Verify exec_structured with cwd and env.""" + label = f" [{vm.name}]" + print(f"{label} exec_structured(cwd=/tmp, env={{MSG=hello}}) ...") + try: + result = vm.exec_structured( + ["sh", "-c", 'echo "cwd=$(pwd)" "MSG=$MSG"'], + cwd="/tmp", + env={"MSG": "hello"}, ) + except MultipassError as exc: + print(f"{label} exec_structured FAILED — {exc}", file=sys.stderr) + return False + stdout = result.stdout.strip() + ok = "cwd=/tmp" in stdout and "MSG=hello" in stdout + print(f"{label} stdout: {stdout}") + if not ok: + print(f"{label} exec_structured FAILED — unexpected output", file=sys.stderr) + return ok + + +def _test_stop_start(vm: MultipassVM, timeout: float) -> bool: + """Stop the VM, verify state, start it, verify running + SSH.""" + label = f" [{vm.name}]" + print(f"{label} stop ...") + try: + vm.stop() + except MultipassError as exc: + print(f"{label} stop FAILED — {exc}", file=sys.stderr) + return False + + info = vm.info() + if info.state != VmState.STOPPED: + print(f"{label} stop FAILED — expected state=Stopped, got {info.state}", file=sys.stderr) + return False + print(f"{label} state={info.state.value} ✓") + + print(f"{label} start ...") + try: + vm.start() + except MultipassError as exc: + print(f"{label} start FAILED — {exc}", file=sys.stderr) + return False + + print(f"{label} waiting for SSH after start ...") + try: + vm.wait_ready(timeout=timeout, port=22) + except MultipassError as exc: + print(f"{label} start FAILED — SSH not reachable: {exc}", file=sys.stderr) + return False + print(f"{label} SSH reachable ✓") + return True + + +def _test_restart(vm: MultipassVM, timeout: float) -> bool: + """Restart the VM and verify it comes back.""" + label = f" [{vm.name}]" + print(f"{label} restart ...") + try: + vm.restart() + except MultipassError as exc: + print(f"{label} restart FAILED — {exc}", file=sys.stderr) + return False + + # VM state during restart is transient; wait for SSH + try: + vm.wait_ready(timeout=timeout, port=22) + except MultipassError as exc: + print(f"{label} restart FAILED — SSH not reachable: {exc}", file=sys.stderr) + return False + print(f"{label} SSH reachable after restart ✓") + return True + + +def _test_transfer(vm: MultipassVM) -> bool: + """Transfer a file host→VM, verify, VM→host, verify round-trip.""" + label = f" [{vm.name}]" + print(f"{label} transfer (host → VM → host) ...") + + content = f"e2e-transfer-test-{int(time.time())}" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf: + tf.write(content) + host_src = tf.name + + vm_dest = f"{vm.name}:/tmp/e2e_transfer_in.txt" + try: + vm.transfer(host_src, vm_dest) + except MultipassError as exc: + print(f"{label} transfer (host→VM) FAILED — {exc}", file=sys.stderr) + Path(host_src).unlink(missing_ok=True) + return False + + # Verify file arrived + result = vm.exec(["cat", "/tmp/e2e_transfer_in.txt"]) + if result.stdout.strip() != content: + print(f"{label} transfer (host→VM) FAILED — content mismatch", file=sys.stderr) + print(f"{label} expected: {content!r}") + print(f"{label} got: {result.stdout.strip()!r}") + Path(host_src).unlink(missing_ok=True) + return False + print(f"{label} host→VM ✓ ({len(content)} bytes)") + + # Transfer back + host_back = host_src + ".back" + try: + vm.transfer(f"{vm.name}:/tmp/e2e_transfer_in.txt", host_back) + except MultipassError as exc: + print(f"{label} transfer (VM→host) FAILED — {exc}", file=sys.stderr) + Path(host_src).unlink(missing_ok=True) + return False + + roundtrip = Path(host_back).read_text() + Path(host_back).unlink(missing_ok=True) + Path(host_src).unlink(missing_ok=True) + + if roundtrip != content: + print(f"{label} transfer (VM→host) FAILED — content mismatch", file=sys.stderr) + return False + print(f"{label} VM→host ✓ ({len(roundtrip)} bytes)") + return True + + +def _test_clone(vm: MultipassVM, timeout: float) -> tuple[bool, MultipassVM | None]: + """Clone the VM, verify the clone is reachable. Returns (ok, clone_vm).""" + label = f" [{vm.name}]" + clone_name = f"{vm.name}-clone" + print(f"{label} clone → {clone_name} ...") + + try: + cloned = vm.clone(clone_name) + except MultipassError as exc: + print(f"{label} clone FAILED — {exc}", file=sys.stderr) + return False, None + + try: + cloned.wait_ready(timeout=timeout, port=22) + except MultipassError as exc: + print(f"{label} clone FAILED — SSH not reachable: {exc}", file=sys.stderr) + _safe_delete(cloned) + return False, None + + result = cloned.exec(["hostname"]) + print(f"{label} hostname={result.stdout.strip()} exit={result.returncode} ✓") + return result.success, cloned + + +def _test_snapshot_restore(vm: MultipassVM, timeout: float) -> bool: + """Create snapshot, stop VM, restore, start, verify.""" + label = f" [{vm.name}]" + snap_name = "e2e-snap" + + print(f"{label} snapshot '{snap_name}' ...") + try: + vm.snapshot(snap_name, comment="e2e test snapshot") + except MultipassError as exc: + print(f"{label} snapshot FAILED — {exc}", file=sys.stderr) + return False + + snaps = vm.snapshots() + if not any(s.name == snap_name for s in snaps): + print(f"{label} snapshot FAILED — snapshot not found in list", file=sys.stderr) + return False + print(f"{label} snapshot created ✓") + + print(f"{label} stop (for restore) ...") + try: + vm.stop() + except MultipassError as exc: + print(f"{label} stop FAILED — {exc}", file=sys.stderr) + return False + + print(f"{label} restore '{snap_name}' ...") + try: + vm.restore(snap_name, destructive=True) + except MultipassError as exc: + print(f"{label} restore FAILED — {exc}", file=sys.stderr) + return False + print(f"{label} restored ✓") + + print(f"{label} start after restore ...") + try: + vm.start() + vm.wait_ready(timeout=timeout, port=22) + except MultipassError as exc: + print(f"{label} start after restore FAILED — {exc}", file=sys.stderr) + return False + print(f"{label} SSH reachable after restore ✓") + return True + + +# ------------------------------------------------------------------ helpers + + +def _safe_delete(vm: MultipassVM) -> None: + try: + vm.delete(purge=True) + except MultipassError: + pass + + +def _skip_reason(feature: str, skipped: set[str], count: int) -> str | None: + if "all" in skipped or feature in skipped: + return "skipped by --skip" + if count > 1 and feature != "exec_structured": + return "skipped (--count > 1)" + return None + + +# ------------------------------------------------------------------ main def main() -> None: parser = argparse.ArgumentParser( - description="End-to-end VM lifecycle test (create, verify, delete)." + description="End-to-end VM lifecycle test (create, verify, feature test, delete)." ) parser.add_argument( "--name", default=None, help="VM name; used as prefix when --count > 1 (auto-generated if omitted).", ) - parser.add_argument( - "--cpus", type=int, default=1, - help="Number of CPUs (default: 1).", - ) - parser.add_argument( - "--memory", default="1G", - help="Memory size (default: 1G).", - ) - parser.add_argument( - "--disk", default="5G", - help="Disk size (default: 5G).", - ) - parser.add_argument( - "--image", default=None, - help="Ubuntu image (default: latest LTS).", - ) + parser.add_argument("--cpus", type=int, default=1, help="Number of CPUs (default: 1).") + parser.add_argument("--memory", default="1G", help="Memory size (default: 1G).") + parser.add_argument("--disk", default="5G", help="Disk size (default: 5G).") + parser.add_argument("--image", default=None, help="Ubuntu image (default: latest LTS).") parser.add_argument( "--timeout", type=float, default=300, help="Max seconds to wait for SSH readiness (default: 300).", @@ -121,16 +319,13 @@ def main() -> None: ) parser.add_argument( "--configs", - help=( - "JSON array of VmConfig objects, e.g. " - "'[{\"name\":\"web\",\"cpus\":2},{\"name\":\"db\",\"cpus\":4}]'. " - "Mutually exclusive with --name/--cpus/--memory/--disk/--image/--count." - ), + help="JSON array of VmConfig objects. Mutually exclusive with --name/--cpus/--memory/--disk/--image/--count.", ) parser.add_argument( - "--list-images", action="store_true", - help="List available images and exit.", + "--skip", default="", + help="Comma-separated feature names to skip: exec_structured, stop_start, restart, transfer, clone, snapshot_restore. Use 'all' to skip all feature tests.", ) + parser.add_argument("--list-images", action="store_true", help="List available images and exit.") args = parser.parse_args() _check_mutually_exclusive(args) @@ -138,6 +333,8 @@ def main() -> None: if args.count < 1: raise SystemExit("--count must be at least 1") + skipped = {s.strip() for s in args.skip.split(",") if s.strip()} + client = MultipassClient() if args.list_images: @@ -148,6 +345,7 @@ def main() -> None: print(f"{','.join(img.aliases):<30} {img.os:<12} {img.release:<16} {img.version:<12}") raise SystemExit(0) + # --- build configs ------------------------------------------------------- if args.configs: try: raw = json.loads(args.configs) @@ -181,15 +379,18 @@ def main() -> None: names = [cfg.name for cfg in configs] num_vms = len(configs) vms: list[MultipassVM] = [] + clones: list[MultipassVM] = [] exit_code = 0 + failed_features: list[str] = [] try: + # ============================================================ [1/5] launch if num_vms == 1: cfg = configs[0] - print(f"[1/3] Launching VM '{names[0]}' (cpus={cfg.cpus}, memory={cfg.memory}, disk={cfg.disk}) ...") + print(f"[1/{_STEPS}] Launching VM '{names[0]}' (cpus={cfg.cpus}, memory={cfg.memory}, disk={cfg.disk}) ...") else: cfg0 = configs[0] - print(f"[1/3] Launching {num_vms} VMs in parallel (cpus={cfg0.cpus}, memory={cfg0.memory}, disk={cfg0.disk}) ...") + print(f"[1/{_STEPS}] Launching {num_vms} VMs in parallel (cpus={cfg0.cpus}, memory={cfg0.memory}, disk={cfg0.disk}) ...") for name in names: print(f" - {name}") t0 = time.monotonic() @@ -198,12 +399,49 @@ def main() -> None: verb = "launch" if num_vms == 1 else f"all {num_vms} launches" print(f" {verb} completed in {dt:.1f}s") - print(f"[2/3] Verifying {num_vms} VM(s) ...") + # ============================================================ [2/5] verify + print(f"[2/{_STEPS}] Basic verification ({num_vms} VM(s)) ...") for i, vm in enumerate(vms, start=1): rc = _verify_vm(vm, i, num_vms, args.timeout) if rc != 0: exit_code = 1 + # ============================================================ [3/5] features + if num_vms == 1 and "all" not in skipped: + print(f"[3/{_STEPS}] Feature tests ...") + primary = vms[0] + + tests = [ + ("exec_structured", lambda: _test_exec_structured(primary)), + ("stop_start", lambda: _test_stop_start(primary, args.timeout)), + ("restart", lambda: _test_restart(primary, args.timeout)), + ("transfer", lambda: _test_transfer(primary)), + ("clone", lambda: _test_clone_wrapper(primary, args.timeout, clones)), + ("snapshot_restore", lambda: _test_snapshot_restore(primary, args.timeout)), + ] + + for name, test_fn in tests: + reason = _skip_reason(name, skipped, num_vms) + if reason: + print(f" ⏭ {name}: {reason}") + continue + try: + ok = test_fn() + except Exception as exc: + print(f" ✗ {name}: unexpected error — {exc}", file=sys.stderr) + failed_features.append(name) + continue + if ok: + print(f" ✓ {name}") + else: + failed_features.append(name) + print(f" ✗ {name} FAILED") + else: + if num_vms > 1: + print(f"[3/{_STEPS}] Feature tests skipped (--count > 1)") + else: + print(f"[3/{_STEPS}] Feature tests skipped (--skip all)") + except MultipassError as exc: print(f"\nERROR: {exc}", file=sys.stderr) exit_code = 2 @@ -211,9 +449,15 @@ def main() -> None: print("\nInterrupted.", file=sys.stderr) exit_code = 130 + # ============================================================ [4/5] cleanup clones + print(f"[4/{_STEPS}] Cleaning up clones ({len(clones)} VM(s)) ...") + for c in clones: + _safe_delete(c) + + # ============================================================ [5/5] cleanup VMs if vms: label = _plural_label(names, len(vms)) - print(f"[3/3] Deleting {label} ...") + print(f"[5/{_STEPS}] Deleting {label} ...") try: for vm in vms: vm.delete(purge=True) @@ -222,17 +466,32 @@ def main() -> None: print(f" cleanup failed: {exc}", file=sys.stderr) exit_code = 3 elif exit_code != 130: - print("[3/3] No VMs to clean up.") + print(f"[5/{_STEPS}] No VMs to clean up.") + # --------------------------------------------------------------- summary print() label = _plural_label(names, len(vms)) - if exit_code == 0: - print(f"SUCCESS — {label} completed full lifecycle.") + if exit_code == 0 and not failed_features: + print(f"SUCCESS — {label} completed full lifecycle, all features passed.") else: - print(f"FAILURE (exit code {exit_code}) — see errors above.") + parts = [] + if exit_code != 0: + parts.append(f"exit code {exit_code}") + if failed_features: + parts.append(f"features failed: {', '.join(failed_features)}") + print(f"FAILURE ({'; '.join(parts)}) — see errors above.") + if exit_code == 0: + exit_code = 1 raise SystemExit(exit_code) +def _test_clone_wrapper(vm, timeout, clones): + ok, cloned = _test_clone(vm, timeout) + if cloned is not None: + clones.append(cloned) + return ok + + if __name__ == "__main__": main() From f953a86a056df77a0f65579f8276584129d12dc0 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Mon, 18 May 2026 18:00:46 +0200 Subject: [PATCH 23/24] docs: update README with VmConfig, CloudInitConfig, launch_many, exec_structured, e2e, testing.py Co-Authored-By: Claude Opus 4.7 --- README.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9c6bfab..9e45bcb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ uv add git+https://github.com/miciav/multipass-sdk.git To pin to a specific commit or tag: ```bash -uv add git+https://github.com/miciav/multipass-sdk.git@v0.3.0 +uv add git+https://github.com/miciav/multipass-sdk.git@v0.6.0 ``` Multipass itself must be installed on the machine where the SDK is used at runtime. It is **not** required for unit tests. @@ -29,13 +29,22 @@ Multipass itself must be installed on the machine where the SDK is used at runti ## Quick start ```python -from multipass import MultipassClient +from multipass import MultipassClient, VmConfig client = MultipassClient() # Launch a VM (name is auto-generated if omitted) vm = client.launch(name="my-vm", cpus=2, memory="1G", disk="10G", image="22.04") +# Or use a VmConfig for reusable configs +vm = client.launch(VmConfig(name="my-vm", cpus=4, memory="8G")) + +# Launch multiple VMs in parallel (rolls back on failure) +vms = client.launch_many([ + VmConfig(name="web", cpus=2), + VmConfig(name="db", cpus=4, memory="8G"), +]) + # Wait until SSH is reachable, then connect ip = vm.wait_ready(timeout=180, port=22) print(f"VM ready at {ip}") @@ -44,6 +53,10 @@ print(f"VM ready at {ip}") result = vm.exec(["uname", "-r"]) print(result.stdout) +# Structured exec with cwd and env +result = vm.exec_structured(["sh", "-c", 'echo "$PWD $MSG"'], + cwd="/tmp", env={"MSG": "hello"}) + # Lifecycle vm.stop() vm.start() @@ -66,7 +79,8 @@ client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary | Method | Description | |--------|-------------| -| `launch(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Launch a new VM | +| `launch(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Launch a new VM. Accepts `str \| VmConfig \| None` as first argument | +| `launch_many(configs, *, max_workers) → list[MultipassVM]` | Launch multiple VMs in parallel; rolls back all on any failure | | `ensure_running(name, image, *, cpus, memory, disk, cloud_init, cloud_init_config) → MultipassVM` | Idempotent: launch, start, or no-op so the VM ends up Running | | `get_vm(name) → MultipassVM` | Get a VM object by name | | `list() → list[VmInfo]` | List all VMs | @@ -92,6 +106,7 @@ client = MultipassClient(cmd="multipass") # cmd: path to the CLI binary | `recover()` | Recover a VM in error state | | `delete(*, purge)` | Delete the VM (soft or permanent) | | `exec(command: list[str]) → CommandResult` | Run a command in the VM | +| `exec_structured(argv, *, env, cwd) → CommandResult` | Structured exec: builds the bash prologue (cd + export) from typed arguments | | `transfer(source, dest)` | Transfer files between host and VM (recursive) | | `mount(source, target, *, mount_type, uid_map, gid_map)` | Mount a host directory | | `unmount(mount)` | Unmount a directory | @@ -118,6 +133,53 @@ ip = vm.wait_ready(timeout=180, port=22) Both raise `MultipassTimeoutError` if the deadline is exceeded. +### VmConfig + +Reusable configuration for VM launches, shared across `launch()`, `launch_many()`, and `ensure_running()`: + +```python +from multipass import VmConfig + +cfg = VmConfig(name="worker", cpus=4, memory="8G", disk="30G", image="22.04") + +vm = client.launch(cfg) +vms = client.launch_many([cfg, VmConfig(name="worker2", cpus=2)]) +``` + +Fields: `name`, `image`, `cpus` (default 1), `memory` (default "1G"), `disk` (default "5G"), `cloud_init`, `cloud_init_config`. + +### CloudInitConfig + +Structured cloud-init configuration as an alternative to raw dicts: + +```python +from multipass import CloudInitConfig + +cfg = CloudInitConfig( + packages=["git", "curl"], + ssh_authorized_keys=["ssh-ed25519 AAAA..."], + runcmd=[["apt-get", "update"], ["apt-get", "upgrade", "-y"]], +) +vm = client.launch( + name="my-vm", + cloud_init_config=cfg.to_dict(), +) +``` + +Fields: `packages`, `ssh_authorized_keys`, `runcmd`, `write_files`, `users` — all optional. `to_dict()` returns only the fields that were set. + +### launch_many + +Launch multiple VMs concurrently. If any launch fails, already-created VMs are deleted: + +```python +configs = [ + VmConfig(name="web", cpus=2, memory="4G"), + VmConfig(name="db", cpus=4, memory="16G", disk="100G"), +] +vms = client.launch_many(configs, max_workers=4) +``` + ### File transfer Use `instance-name:/path` notation for VM paths, plain paths for host paths: @@ -252,7 +314,9 @@ Inject a `FakeBackend` to unit-test code that uses the SDK: ```python import json -from multipass import MultipassClient, FakeBackend, CommandResult +from multipass import MultipassClient +from multipass.testing import FakeBackend +from multipass import CommandResult backend = FakeBackend({ ("multipass", "list", "--format", "json"): CommandResult( @@ -265,7 +329,7 @@ client = MultipassClient(backend=backend) vms = client.list() # no Multipass required ``` -`FakeBackend` also supports queued responses for polling scenarios: +`FakeBackend` also supports queued responses for polling scenarios and tracks `cwd`/`env` for assertions: ```python backend = FakeBackend() @@ -273,6 +337,9 @@ backend.push("multipass", "info", "my-vm", "--format", "json", result=CommandResult(..., stdout=info_no_ip)) backend.push("multipass", "info", "my-vm", "--format", "json", result=CommandResult(..., stdout=info_with_ip)) + +assert backend.last_cwd() is None +assert backend.last_env() is None ``` --- @@ -290,6 +357,23 @@ uv run pytest tests/unit/ -v uv run pytest -m integration -v ``` +## End-to-end script + +The SDK ships a `multipass-vm-e2e` console script for full lifecycle verification: + +```bash +uv run multipass-vm-e2e +uv run multipass-vm-e2e --name my-test --cpus 2 --memory 4G --disk 10G +uv run multipass-vm-e2e --count 3 +uv run multipass-vm-e2e --configs '[{"name":"web","cpus":2},{"name":"db","cpus":4}]' +uv run multipass-vm-e2e --list-images +uv run multipass-vm-e2e --skip transfer,clone +``` + +Five-stage pipeline: launch → basic verification → feature tests (exec_structured, stop/start, restart, transfer, clone, snapshot/restore) → cleanup clones → delete VMs. + +Feature tests run only in single-VM mode (`--count 1`). Use `--skip all` for a basic lifecycle-only test or `--skip ,...` to skip individual tests. + The integration test suite covers: full VM lifecycle, resources, soft delete + purge, `wait_for_ip`, `wait_ready` + SSH, suspend/resume, file transfer, snapshot/restore, clone, cloud-init, and error handling. --- From 402c4061e6b32cc6dda5e7a9876fa7cefd6f9ae1 Mon Sep 17 00:00:00 2001 From: miciav <5889596+miciav@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:14:22 +0200 Subject: [PATCH 24/24] fix: never inherit caller stdin in SubprocessBackend (multipass exec hangs on writer-less pty) --- .../plans/2026-06-11-exec-stdin-devnull.md | 94 +++++++++++++++++++ src/multipass/_backend.py | 9 +- tests/unit/test_backend_stdin.py | 18 ++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-11-exec-stdin-devnull.md create mode 100644 tests/unit/test_backend_stdin.py diff --git a/docs/superpowers/plans/2026-06-11-exec-stdin-devnull.md b/docs/superpowers/plans/2026-06-11-exec-stdin-devnull.md new file mode 100644 index 0000000..97b82a4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-exec-stdin-devnull.md @@ -0,0 +1,94 @@ +# Exec stdin DEVNULL Implementation Plan (multipass-sdk) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop `multipass` CLI invocations from inheriting the caller's stdin, which makes `multipass exec` hang forever when stdin is a pty with no writer. + +**Architecture:** One-line change in `SubprocessBackend.run` (`stdin=subprocess.DEVNULL`): the SDK is non-interactive by design — no SDK call ever feeds data through stdin, so the multipass CLI must never be left waiting on it. Single unit test pins the contract. + +**Tech Stack:** Python, subprocess, pytest. + +--- + +## Context (why) + +Found during nanofaas PR #118 smoke debugging (2026-06-11): nanofaas wraps its e2e runs in `script` (pty) to defeat rich's no-tty output suppression. `SubprocessBackend.run` uses `subprocess.run(args, capture_output=True, text=True, cwd=cwd, env=env)` — stdout/stderr are piped but **stdin is inherited**. `multipass exec` forwards stdin to the remote process; with an inherited pty that never receives input or EOF, the exec session never terminates: three independent runs hung 1h+ on a sub-second remote command (`multipass exec ... authorized_keys` setup), while the identical command with socket/null stdin completed in 0.3 s. + +`capture_output=True` already declares this backend non-interactive; closing stdin makes that explicit and immune to the caller's stdin type. + +## Repo state note + +This repo currently has untracked `docs/` and `uv.lock` — leave them as they are; stage only the files this plan touches. + +--- + +### Task 1: stdin=DEVNULL in SubprocessBackend + +**Files:** +- Modify: `src/multipass/_backend.py` (the `subprocess.run(...)` call in `SubprocessBackend.run`, ~line 43) +- Test: the unit test file covering the backend (find it: `grep -rln "SubprocessBackend" tests/unit/`; create `tests/unit/test_backend_stdin.py` if none fits) + +- [ ] **Step 1: Write the failing test** + +```python +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from multipass._backend import SubprocessBackend + + +def test_subprocess_backend_never_inherits_stdin() -> None: + """multipass exec forwards stdin to the remote command; an inherited pty + with no writer makes the session hang forever. The backend is + non-interactive by design, so stdin must be closed explicitly.""" + backend = SubprocessBackend() + completed = MagicMock(returncode=0, stdout="", stderr="") + with patch("multipass._backend.subprocess.run", return_value=completed) as mock_run: + backend.run(["multipass", "exec", "vm", "--", "true"]) + + assert mock_run.call_args.kwargs.get("stdin") == subprocess.DEVNULL +``` + +Adapt only the plumbing if reality differs (e.g. `backend.run` signature, how `CommandResult` is built from the mock — if the constructor needs more attributes on `completed`, add them); the `stdin == subprocess.DEVNULL` assertion is the requirement. + +- [ ] **Step 2: Run it — expect FAIL** (`stdin` kwarg absent → `None != DEVNULL`). + +Run: `uv run pytest tests/unit -q` (check `pyproject.toml` for the project's actual test invocation/coverage flags; use the full unit suite if single files trip a coverage gate). + +- [ ] **Step 3: Implement (one line)** + +In `src/multipass/_backend.py`: + +```python + proc = subprocess.run( + args, + capture_output=True, + text=True, + cwd=cwd, + env=env, + stdin=subprocess.DEVNULL, + ) +``` + +- [ ] **Step 4: Run the FULL test suite** (unit + integration if they don't require a live multipass; check how CI/`pyproject` runs them): `uv run pytest tests/unit -q` → green. If integration tests run against a real multipass on this machine and one is available, run them too — this change is exactly the kind integration tests exist for. + +- [ ] **Step 5: Commit** + +```bash +git add src/multipass/_backend.py tests/unit/ +git commit -m "fix: never inherit caller stdin in SubprocessBackend (multipass exec hangs on writer-less pty)" +``` + +--- + +### Task 2: Publish (USER DECISION — this is the user's separate project) + +- [ ] **Step 1:** The user reviews and pushes (plain push to `main` or PR, their call): `git push origin main` from `~/Downloads/multipass-sdk`. +- [ ] **Step 2:** Record the new short SHA (`git rev-parse --short HEAD`) — it gates **Task 4 of the nanofaas plan** `mcFaas/docs/superpowers/plans/2026-06-11-pr118-followups.md`, which bumps `multipass-sdk @ git+...@83b3704` → `@` in `tools/workflow-tasks/pyproject.toml` and `tools/controlplane/pyproject.toml`. + +## Out of scope (deliberate) + +- No behavior flag for interactive use: nothing in this SDK exposes interactive exec; if that ever becomes a feature, it needs its own streaming API, not inherited stdin. +- The sibling SDKs (azure-vm-sdk, proxmox-sdk) may have the same pattern — worth a 5-minute audit, but not part of this plan. diff --git a/src/multipass/_backend.py b/src/multipass/_backend.py index 2190cdc..532ff7d 100644 --- a/src/multipass/_backend.py +++ b/src/multipass/_backend.py @@ -40,7 +40,14 @@ def run( env: dict[str, str] | None = None, ) -> CommandResult: try: - proc = subprocess.run(args, capture_output=True, text=True, cwd=cwd, env=env) + proc = subprocess.run( + args, + capture_output=True, + text=True, + cwd=cwd, + env=env, + stdin=subprocess.DEVNULL, + ) except FileNotFoundError: raise MultipassNotInstalledError() return CommandResult( diff --git a/tests/unit/test_backend_stdin.py b/tests/unit/test_backend_stdin.py new file mode 100644 index 0000000..6b54a6d --- /dev/null +++ b/tests/unit/test_backend_stdin.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import subprocess +from unittest.mock import MagicMock, patch + +from multipass._backend import SubprocessBackend + + +def test_subprocess_backend_never_inherits_stdin() -> None: + """multipass exec forwards stdin to the remote command; an inherited pty + with no writer makes the session hang forever. The backend is + non-interactive by design, so stdin must be closed explicitly.""" + backend = SubprocessBackend() + completed = MagicMock(returncode=0, stdout="", stderr="") + with patch("multipass._backend.subprocess.run", return_value=completed) as mock_run: + backend.run(["multipass", "exec", "vm", "--", "true"]) + + assert mock_run.call_args.kwargs.get("stdin") == subprocess.DEVNULL