Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit ac364c4

Browse files
authored
Merge pull request #793 from evakhoni/qemu_resize
Add disk resize support for QEMU driver
2 parents 4b31a61 + be4d299 commit ac364c4

File tree

3 files changed

+201
-1
lines changed

3 files changed

+201
-1
lines changed

packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from contextlib import contextmanager
22

3+
import click
34
from jumpstarter_driver_composite.client import CompositeClient
45
from jumpstarter_driver_network.adapters import FabricAdapter, NovncAdapter
56

7+
from jumpstarter.client.decorators import driver_click_group
8+
69

710
class QemuClient(CompositeClient):
811
@property
@@ -17,6 +20,14 @@ def username(self) -> str:
1720
def password(self) -> str:
1821
return self.call("get_password")
1922

23+
def set_disk_size(self, size: str) -> None:
24+
"""Set the disk size for resizing before boot."""
25+
self.call("set_disk_size", size)
26+
27+
def set_memory_size(self, size: str) -> None:
28+
"""Set the memory size for next boot."""
29+
self.call("set_memory_size", size)
30+
2031
@contextmanager
2132
def novnc(self):
2233
with NovncAdapter(client=self.vnc) as url:
@@ -30,3 +41,30 @@ def shell(self):
3041
connect_kwargs={"password": self.password},
3142
) as conn:
3243
yield conn
44+
45+
def cli(self):
46+
@driver_click_group(self)
47+
def base():
48+
"""QEMU virtual machine operations"""
49+
pass
50+
51+
@base.group()
52+
def resize():
53+
"""Resize QEMU resources"""
54+
pass
55+
56+
@resize.command(name="disk")
57+
@click.argument("size")
58+
def resize_disk(size):
59+
"""Resize the root disk (e.g., 20G). Run before power on."""
60+
self.set_disk_size(size)
61+
click.echo(f"Disk will be resized to {size} on next power on")
62+
63+
@resize.command(name="memory")
64+
@click.argument("size")
65+
def resize_memory(size):
66+
"""Set memory size (e.g., 2G, 4G). Takes effect on next boot."""
67+
self.set_memory_size(size)
68+
click.echo(f"Memory will be set to {size} on next power on")
69+
70+
return base

packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import platform
7+
import shutil
78
from collections.abc import AsyncGenerator
89
from dataclasses import dataclass, field
910
from functools import cached_property
@@ -20,7 +21,7 @@
2021
from jumpstarter_driver_opendal.driver import FlasherInterface
2122
from jumpstarter_driver_power.driver import PowerInterface, PowerReading
2223
from jumpstarter_driver_pyserial.driver import PySerial
23-
from pydantic import BaseModel, Field, validate_call
24+
from pydantic import BaseModel, ByteSize, Field, TypeAdapter, ValidationError, validate_call
2425
from qemu.qmp import QMPClient
2526
from qemu.qmp.protocol import ConnectError, Runstate
2627

@@ -169,6 +170,7 @@ async def on(self) -> None: # noqa: C901
169170
proc.check_returncode()
170171
info = json.loads(proc.stdout)
171172
image_format = info.get("format", "raw")
173+
current_virtual_size = info.get("virtual-size") or root.stat().st_size
172174
match image_format:
173175
case "raw" | "qcow2" | "qcow" | "vmdk":
174176
image_driver = image_format
@@ -177,6 +179,34 @@ async def on(self) -> None: # noqa: C901
177179
except CalledProcessError:
178180
self.logger.warning("unable to detect image format, assuming raw")
179181
image_driver = "raw"
182+
current_virtual_size = root.stat().st_size
183+
184+
# Resize disk if configured
185+
if self.parent.disk_size:
186+
requested = self.parent._parse_size(self.parent.disk_size)
187+
188+
if requested < current_virtual_size:
189+
raise RuntimeError(
190+
f"Shrinking disk is not supported: current {ByteSize(current_virtual_size).human_readable()}, "
191+
f"requested {self.parent.disk_size}"
192+
)
193+
194+
available = shutil.disk_usage(root.parent).free
195+
if requested > available:
196+
raise RuntimeError(
197+
f"Not enough disk space: need {ByteSize(requested).human_readable()}, "
198+
f"only {ByteSize(available).human_readable()} available"
199+
)
200+
201+
if requested > current_virtual_size:
202+
self.logger.info(f"Resizing disk to {ByteSize(requested).human_readable()}")
203+
proc = await run_process(
204+
["qemu-img", "resize", str(root), str(requested)],
205+
stdout=PIPE,
206+
stderr=PIPE,
207+
)
208+
if proc.returncode != 0:
209+
raise RuntimeError(f"Failed to resize disk: {proc.stderr.decode()}")
180210

181211
cmdline += [
182212
"-blockdev",
@@ -254,6 +284,7 @@ class Qemu(Driver):
254284

255285
smp: int = 2
256286
mem: str = "512M"
287+
disk_size: str | None = None # e.g., "20G" (resize disk before boot)
257288

258289
hostname: str = "demo"
259290
username: str = "jumpstarter"
@@ -372,3 +403,24 @@ def get_username(self) -> str:
372403
@validate_call(validate_return=True)
373404
def get_password(self) -> str:
374405
return self.password
406+
407+
def _parse_size(self, size: str) -> int:
408+
"""Parse size string (e.g., '20G') to bytes."""
409+
try:
410+
return int(TypeAdapter(ByteSize).validate_python(size + "iB" if size[-1] in "kmgtKMGT" else size))
411+
except (ValidationError, IndexError):
412+
raise ValueError(f"Invalid size: '{size}'. Use e.g. '20G', '512M', '2T'") from None
413+
414+
@export
415+
@validate_call(validate_return=True)
416+
def set_disk_size(self, size: str) -> None:
417+
"""Set the disk size for resizing before boot."""
418+
self._parse_size(size) # Validate
419+
self.disk_size = size
420+
421+
@export
422+
@validate_call(validate_return=True)
423+
def set_memory_size(self, size: str) -> None:
424+
"""Set the memory size for next boot."""
425+
self._parse_size(size) # Validate
426+
self.mem = size

packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import json
12
import os
23
import platform
34
import sys
45
import tarfile
56
from pathlib import Path
7+
from types import SimpleNamespace
8+
from unittest.mock import AsyncMock, patch
69

710
import pytest
811
import requests
@@ -13,6 +16,12 @@
1316
from jumpstarter.common.utils import serve
1417

1518

19+
@pytest.fixture
20+
def anyio_backend():
21+
"""Use only asyncio backend for anyio tests."""
22+
return "asyncio"
23+
24+
1625
@pytest.fixture(scope="session")
1726
def ovmf(tmpdir_factory):
1827
tmp_path = tmpdir_factory.mktemp("ovmf")
@@ -91,3 +100,104 @@ def test_driver_qemu(tmp_path, ovmf):
91100
assert s.run("uname -r").stdout.strip() == f"6.11.4-301.fc41.{arch}"
92101

93102
qemu.power.off()
103+
104+
105+
@pytest.fixture
106+
def resize_test():
107+
"""Create a Qemu driver with a sparse root disk, cleanup after test."""
108+
driver = None
109+
110+
def _create(disk_size, current_size_gb):
111+
nonlocal driver
112+
driver = Qemu(disk_size=disk_size)
113+
root = Path(driver._tmp_dir.name) / "root"
114+
root.write_bytes(b"")
115+
os.truncate(root, current_size_gb * 1024**3)
116+
return driver, current_size_gb * 1024**3
117+
118+
yield _create
119+
120+
if driver:
121+
driver._tmp_dir.cleanup()
122+
123+
124+
def _mock_qemu_img_info(virtual_size):
125+
"""Return a mock for run_process that simulates qemu-img info."""
126+
async def mock(cmd, **kwargs):
127+
result = AsyncMock()
128+
result.returncode = 0
129+
result.stdout = json.dumps({"format": "raw", "virtual-size": virtual_size}).encode()
130+
result.check_returncode = lambda: None
131+
return result
132+
return mock
133+
134+
135+
@pytest.mark.anyio
136+
async def test_resize_shrink_blocked(resize_test):
137+
"""Shrinking disk should raise RuntimeError."""
138+
driver, current = resize_test("10G", 20) # requested: 10G, current: 20G
139+
140+
with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)):
141+
with pytest.raises(RuntimeError, match="Shrinking disk is not supported"):
142+
await driver.children["power"].on()
143+
144+
145+
@pytest.mark.anyio
146+
async def test_resize_insufficient_space_blocked(resize_test):
147+
"""Resize beyond available host space should raise RuntimeError."""
148+
driver, current = resize_test("100G", 10) # requested: 100G, current: 10G
149+
150+
mock_usage = SimpleNamespace(free=5 * 1024**3) # only 5G free
151+
152+
with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)):
153+
with patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage):
154+
with pytest.raises(RuntimeError, match="Not enough disk space"):
155+
await driver.children["power"].on()
156+
157+
158+
@pytest.mark.anyio
159+
async def test_resize_succeeds(resize_test):
160+
"""Resize should call qemu-img resize with correct size."""
161+
driver, current = resize_test("20G", 10) # requested: 20G, current: 10G
162+
mock_usage = SimpleNamespace(free=50 * 1024**3)
163+
164+
with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)) as mock_run:
165+
with patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage):
166+
# Mock Popen to stop before actually starting QEMU VM
167+
with patch("jumpstarter_driver_qemu.driver.Popen", side_effect=RuntimeError("mock popen")):
168+
with pytest.raises(RuntimeError, match="mock popen"):
169+
await driver.children["power"].on()
170+
171+
# Find the resize call and verify size argument
172+
resize_calls = [c for c in mock_run.call_args_list if "resize" in c.args[0]]
173+
assert resize_calls, "qemu-img resize should be called"
174+
resize_cmd = resize_calls[0].args[0] # ['qemu-img', 'resize', path, size]
175+
assert resize_cmd[-1] == str(20 * 1024**3)
176+
177+
178+
def test_set_disk_size_valid():
179+
"""Valid size strings should be accepted."""
180+
driver = Qemu()
181+
driver.set_disk_size("20G")
182+
assert driver.disk_size == "20G"
183+
184+
185+
def test_set_disk_size_invalid():
186+
"""Invalid size strings should raise ValueError."""
187+
driver = Qemu()
188+
with pytest.raises(ValueError, match="Invalid size"):
189+
driver.set_disk_size("invalid")
190+
191+
192+
def test_set_memory_size_valid():
193+
"""Valid size strings should be accepted."""
194+
driver = Qemu()
195+
driver.set_memory_size("2G")
196+
assert driver.mem == "2G"
197+
198+
199+
def test_set_memory_size_invalid():
200+
"""Invalid size strings should raise ValueError."""
201+
driver = Qemu()
202+
with pytest.raises(ValueError, match="Invalid size"):
203+
driver.set_memory_size("invalid")

0 commit comments

Comments
 (0)