Skip to content

Commit 9a25775

Browse files
committed
test(qemu): replace Fedora Cloud image with Alpine Linux tiny image
## Motivation Fedora Cloud qcow2 images used in QEMU driver tests weigh ~556 MB (x86_64) + ~519 MB (aarch64) = ~1.1 GB total, taking ~1 minute to download on every CI run. Alpine Linux 3.22.4 UEFI tiny images reduce that to ~127 MB + ~151 MB = ~278 MB — a 74% reduction — and download in ~6 s on GitHub Actions runners, making a cache unnecessary. ## Image replacement x86_64: 556 MB → 127 MB (77%) aarch64: 519 MB → 151 MB (71%) Total: ~1076 MB → ~278 MB (74%) Alpine's nocloud tiny images support UEFI boot on both architectures and use tiny-cloud (a minimal cloud-init alternative) that reads the standard NoCloud CIDATA vfat volume the QEMU driver already generates. ## Driver changes (driver.py) tiny-cloud differs from full cloud-init in two ways: 1. Hostname: tiny-cloud reads @hostname from the YAML key 'hostname' in meta-data, not 'local-hostname'. Add 'hostname' alongside 'local-hostname' so both implementations set it correctly. 2. Password: tiny-cloud ignores plain_text_passwd in the users stanza. Add a runcmd entry to set the password via chpasswd. Credentials are escaped with shlex.quote() so special characters are safe. Full cloud-init images are unaffected. hostport=0 support: Hostfwd now allows hostport=0, which tells QEMU's user-mode networking to pick a free port automatically. After QEMU starts, the driver queries the actual assigned port via QMP 'human-monitor-command info usernet', logs it at INFO level: hostfwd 'ssh': resolved port 0 -> 127.0.0.1:<port> (guest port 22) and stores it in _resolved_hostports so get_hostfwd_port() can return it to the client side. ## Client changes (client.py) shell() now checks for an 'ssh' hostfwd entry by calling get_hostfwd_port('ssh'). If present, it opens a direct fabric Connection to 127.0.0.1:<resolved_port>, bypassing the jumpstarter streaming layer entirely. If no ssh hostfwd is configured (KeyError), it falls back to the original FabricAdapter path over vsock. This is necessary because Alpine uses OpenRC, not systemd, so systemd-ssh-generator never runs and sshd only listens on TCP. Fedora cloud images ship systemd-ssh-generator (systemd >= 256) which automatically binds sshd to AF_VSOCK port 22 inside VMs — that is why the original vsock path worked with Fedora but not Alpine. ## Test changes (driver_test.py) - Pass hostfwd={'ssh': {hostport=0, guestport=22}} so qemu.shell() uses the TCP path with a QEMU-assigned port; no probe socket needed. - Wait for 'bootstrap_complete: done' on the serial console before attempting login, guaranteeing tiny-cloud has set the password and sshd is ready (avoids the race that existed with Fedora too, where the extra setenforce console round-trip happened to provide enough delay). - Press Enter if the GRUB countdown appears to skip the 10 s wait. - Update post-login prompt from bash '[user@host ~]$' to Alpine ash 'host:~$'. - Remove 'sudo setenforce 0' (no SELinux on Alpine). - Relax 'uname -r' assertion to a non-empty check. ## CI changes (python-tests.yaml) Remove the actions/cache steps entirely. Alpine images download in ~6 s on GitHub runners (measured), which is faster than cache restore overhead for files this size.
1 parent ada5fa8 commit 9a25775

4 files changed

Lines changed: 84 additions & 25 deletions

File tree

.github/workflows/python-tests.yaml

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,11 @@ jobs:
120120
run: |
121121
brew install renode/tap/renode
122122
123-
- name: Cache Fedora Cloud images
124-
id: cache-fedora-cloud-images
125-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
126-
with:
127-
path: python/packages/jumpstarter-driver-qemu/images
128-
key: fedora-cloud-43-1.6
129-
130-
- name: Download Fedora Cloud images
131-
if: steps.cache-fedora-cloud-images.outputs.cache-hit != 'true'
123+
- name: Download Alpine cloud images
132124
run: |
133125
for arch in aarch64 x86_64; do
134-
curl -L --fail --output "python/packages/jumpstarter-driver-qemu/images/Fedora-Cloud-Base-Generic-43-1.6.${arch}.qcow2" \
135-
"https://iad.mirror.rackspace.com/fedora/releases/43/Cloud/${arch}/images/Fedora-Cloud-Base-Generic-43-1.6.${arch}.qcow2"
126+
curl -L --fail --output "python/packages/jumpstarter-driver-qemu/images/nocloud_alpine-3.22.4-${arch}-uefi-tiny-r0.qcow2" \
127+
"https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.4-${arch}-uefi-tiny-r0.qcow2"
136128
done
137129
138130
- name: Run pytest

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

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from contextlib import contextmanager
55

66
import click
7+
from fabric import Connection
78
from jumpstarter_driver_composite.client import CompositeClient
89
from jumpstarter_driver_network.adapters import FabricAdapter, NovncAdapter
910

@@ -75,12 +76,25 @@ def novnc(self):
7576

7677
@contextmanager
7778
def shell(self):
78-
with FabricAdapter(
79-
client=self.ssh,
80-
user=self.username,
81-
connect_kwargs={"password": self.password},
82-
) as conn:
83-
yield conn
79+
# If the driver has an 'ssh' hostfwd entry, fetch the actual host port
80+
# (resolving any port=0 assignment) and connect directly over TCP.
81+
# Otherwise fall back to tunnelling through the jumpstarter stream (vsock).
82+
try:
83+
port = int(self.call("get_hostfwd_port", "ssh"))
84+
with Connection(
85+
host="127.0.0.1",
86+
port=port,
87+
user=self.username,
88+
connect_kwargs={"password": self.password},
89+
) as conn:
90+
yield conn
91+
except KeyError:
92+
with FabricAdapter(
93+
client=self.ssh,
94+
user=self.username,
95+
connect_kwargs={"password": self.password},
96+
) as conn:
97+
yield conn
8498

8599
def cli(self):
86100
# Get the base group from CompositeClient which includes all child commands

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import platform
8+
import shlex
89
import shutil
910
from collections.abc import AsyncGenerator
1011
from dataclasses import dataclass, field
@@ -381,6 +382,26 @@ async def on(self) -> None: # noqa: C901
381382
Path(self.parent._pty).unlink(missing_ok=True)
382383
Path(self.parent._pty).symlink_to(pty)
383384

385+
# Resolve any hostport=0 hostfwd entries to the actual port QEMU chose.
386+
# Parse 'info usernet': lines look like "TCP[HOST_FORWARD] fd addr port addr port ..."
387+
# Store resolved ports on the parent so get_hostfwd_port() can return them to clients.
388+
zero_fwds = {k: v for k, v in self.parent.hostfwd.items() if v.hostport == 0}
389+
if zero_fwds:
390+
usernet = await qmp.execute("human-monitor-command", {"command-line": "info usernet"})
391+
self.logger.debug("info usernet output:\n%s", usernet)
392+
for line in usernet.splitlines():
393+
parts = line.split()
394+
if len(parts) >= 6 and "HOST_FORWARD" in parts[0]:
395+
# parts: Protocol[State] fd hostaddr hostport guestaddr guestport ...
396+
actual_hostaddr, actual_hostport, actual_guestport = parts[2], int(parts[3]), int(parts[5])
397+
for k, v in zero_fwds.items():
398+
if v.hostaddr == actual_hostaddr and v.guestport == actual_guestport:
399+
self.logger.info(
400+
"hostfwd '%s': resolved port 0 -> %s:%d (guest port %d)",
401+
k, actual_hostaddr, actual_hostport, actual_guestport,
402+
)
403+
self.parent._resolved_hostports[k] = actual_hostport
404+
384405
await qmp.execute("system_reset")
385406
await qmp.disconnect()
386407

@@ -410,7 +431,7 @@ def close(self):
410431
class Hostfwd(BaseModel):
411432
protocol: Literal["tcp"] = "tcp"
412433
hostaddr: str = "127.0.0.1"
413-
hostport: int = Field(ge=1, le=65535)
434+
hostport: int = Field(ge=0, le=65535) # 0 = let QEMU pick a free port
414435
guestport: int = Field(ge=1, le=65535)
415436

416437

@@ -440,6 +461,8 @@ class Qemu(Driver):
440461
flash_timeout: int = field(default=30 * 60) # 30 minutes
441462

442463
_tmp_dir: TemporaryDirectory = field(init=False, default_factory=TemporaryDirectory)
464+
# Maps hostfwd key -> actual host port after QEMU resolves port 0 assignments
465+
_resolved_hostports: dict[str, int] = field(init=False, default_factory=dict)
443466

444467
@classmethod
445468
def client(cls) -> str:
@@ -512,6 +535,7 @@ def cidata(self) -> TemporaryDirectory:
512535
{
513536
"instance-id": str(self.uuid),
514537
"local-hostname": self.hostname,
538+
"hostname": self.hostname,
515539
}
516540
)
517541
)
@@ -528,12 +552,30 @@ def cidata(self) -> TemporaryDirectory:
528552
"sudo": "ALL=(ALL) NOPASSWD:ALL",
529553
}
530554
],
555+
# runcmd sets the password explicitly for cloud-init implementations
556+
# that do not support plain_text_passwd (e.g. Alpine's tiny-cloud).
557+
# cloud-init ignores runcmd entries it doesn't understand, so this
558+
# is safe to include unconditionally.
559+
# shlex.quote ensures special characters in credentials are safe.
560+
"runcmd": [
561+
f"printf %s {shlex.quote(f'{self.username}:{self.password}')} | chpasswd",
562+
],
531563
}
532564
)
533565
)
534566

535567
return tmp
536568

569+
@export
570+
@validate_call(validate_return=True)
571+
def get_hostfwd_port(self, key: str) -> int:
572+
"""Return the actual host port for a hostfwd entry (resolves port 0 assignments)."""
573+
if key in self._resolved_hostports:
574+
return self._resolved_hostports[key]
575+
if key in self.hostfwd:
576+
return self.hostfwd[key].hostport
577+
raise KeyError(f"hostfwd key {key!r} not found")
578+
537579
@export
538580
@validate_call(validate_return=True)
539581
def get_hostname(self) -> str:

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,31 @@ def get_native_arch_config():
5959
def test_driver_qemu(tmp_path, ovmf):
6060
arch, ovmf_arch = get_native_arch_config()
6161

62+
# Alpine uses OpenRC (not systemd), so systemd-ssh-generator does not run
63+
# and sshd never binds to AF_VSOCK. Use a TCP hostfwd with hostport=0 so
64+
# QEMU picks a free port automatically; the driver resolves the actual port
65+
# from QMP after startup and updates the ssh child accordingly.
6266
with serve(
6367
Qemu(
6468
arch=arch,
6569
default_partitions={
6670
"OVMF_CODE.fd": ovmf / ovmf_arch / "code.fd",
6771
"OVMF_VARS.fd": ovmf / ovmf_arch / "vars.fd",
6872
},
73+
hostfwd={"ssh": {"protocol": "tcp", "hostaddr": "127.0.0.1", "hostport": 0, "guestport": 22}},
6974
)
7075
) as qemu:
7176
hostname = qemu.hostname
7277
username = qemu.username
7378
password = qemu.password
7479

75-
cached_image = Path(__file__).parent.parent / "images" / f"Fedora-Cloud-Base-Generic-43-1.6.{arch}.qcow2"
80+
cached_image = Path(__file__).parent.parent / "images" / f"nocloud_alpine-3.22.4-{arch}-uefi-tiny-r0.qcow2"
7681

7782
if cached_image.exists():
7883
qemu.flasher.flash(cached_image.resolve())
7984
else:
8085
qemu.flasher.flash(
81-
f"https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/{arch}/images/Fedora-Cloud-Base-Generic-43-1.6.{arch}.qcow2",
86+
f"https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.4-{arch}-uefi-tiny-r0.qcow2",
8287
)
8388

8489
qemu.power.on()
@@ -88,16 +93,22 @@ def test_driver_qemu(tmp_path, ovmf):
8893

8994
with qemu.console.pexpect() as p:
9095
p.logfile = sys.stdout.buffer
91-
p.expect_exact(f"{hostname} login:", timeout=600)
96+
# Press Enter if GRUB is waiting. Both the countdown and bootstrap_complete
97+
# can appear before the login prompt, so match whichever comes first.
98+
idx = p.expect_exact(["automatically in ", "bootstrap_complete: done"], timeout=600)
99+
if idx == 0:
100+
# GRUB countdown: skip it, then wait for cloud-init to finish
101+
p.sendline("")
102+
p.expect_exact("bootstrap_complete: done", timeout=600)
103+
# tiny-cloud finished: password is set, sshd is ready
104+
p.expect_exact(f"{hostname} login:", timeout=60)
92105
p.sendline(username)
93106
p.expect_exact("Password:")
94107
p.sendline(password)
95-
p.expect_exact(f"[{username}@{hostname} ~]$")
96-
p.sendline("sudo setenforce 0")
97-
p.expect_exact(f"[{username}@{hostname} ~]$")
108+
p.expect_exact(f"{hostname}:~$")
98109

99110
with qemu.shell() as s:
100-
assert s.run("uname -r").stdout.strip() == f"6.17.1-300.fc43.{arch}"
111+
assert s.run("uname -r").stdout.strip() != ""
101112

102113
qemu.power.off()
103114

0 commit comments

Comments
 (0)