Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions .github/workflows/python-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,11 @@ jobs:
run: |
brew install renode/tap/renode

- name: Cache Fedora Cloud images

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the cache was actually slower than the download.

id: cache-fedora-cloud-images
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: python/packages/jumpstarter-driver-qemu/images
key: fedora-cloud-43-1.6

- name: Download Fedora Cloud images
if: steps.cache-fedora-cloud-images.outputs.cache-hit != 'true'
- name: Download Alpine cloud images
run: |
for arch in aarch64 x86_64; do
curl -L --fail --output "python/packages/jumpstarter-driver-qemu/images/Fedora-Cloud-Base-Generic-43-1.6.${arch}.qcow2" \
"https://iad.mirror.rackspace.com/fedora/releases/43/Cloud/${arch}/images/Fedora-Cloud-Base-Generic-43-1.6.${arch}.qcow2"
curl -L --fail --output "python/packages/jumpstarter-driver-qemu/images/nocloud_alpine-3.22.4-${arch}-uefi-tiny-r0.qcow2" \
"https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.4-${arch}-uefi-tiny-r0.qcow2"
done

- name: Run pytest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import contextmanager

import click
from fabric import Connection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd jumpstarter-driver-qemu -type d

Repository: jumpstarter-dev/jumpstarter

Length of output: 242


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py | head -100

Repository: jumpstarter-dev/jumpstarter

Length of output: 4055


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py | tail -20

Repository: jumpstarter-dev/jumpstarter

Length of output: 913


🏁 Script executed:

grep -n "Connection\|127.0.0.1\|Hostfwd" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 172


🏁 Script executed:

fd jumpstarter-driver-network -type d

Repository: jumpstarter-dev/jumpstarter

Length of output: 242


🏁 Script executed:

find . -name "*jumpstarter-driver-network*" -type d 2>/dev/null | head -5

Repository: jumpstarter-dev/jumpstarter

Length of output: 116


🏁 Script executed:

find . -name "FabricAdapter*" -o -path "*/jumpstarter_driver_network/*" -name "*.py" 2>/dev/null | head -10

Repository: jumpstarter-dev/jumpstarter

Length of output: 979


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/fabric.py | head -60

Repository: jumpstarter-dev/jumpstarter

Length of output: 1419


🏁 Script executed:

grep -n "Hostfwd\|hostaddr" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | head -20

Repository: jumpstarter-dev/jumpstarter

Length of output: 884


🏁 Script executed:

find . -name "*.py" -type f | xargs grep -l "Hostfwd\|hostaddr" 2>/dev/null | head -10

Repository: jumpstarter-dev/jumpstarter

Length of output: 228


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | grep -A 5 -B 5 "hostfwd\|Hostfwd" | head -50

Repository: jumpstarter-dev/jumpstarter

Length of output: 2610


🏁 Script executed:

grep -n "TcpNetwork\|get_hostfwd_port" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 398


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | sed -n '571,590p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 871


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | sed -n '485,495p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 443


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | sed -n '455,475p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 1112


🏁 Script executed:

grep -B5 -A10 "def get_hostfwd_port" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 604


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py | sed -n '78,98p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 1024


🏁 Script executed:

grep -n "export\|get_hostfwd_port\|get_hostaddr" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 480


🏁 Script executed:

grep -n "def get_\|`@export`" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | grep -A1 "def get_"

Repository: jumpstarter-dev/jumpstarter

Length of output: 305


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | sed -n '569,610p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 1847


🏁 Script executed:

grep -B5 -A10 "def get_hostfwd" python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 604


🏁 Script executed:

cat -n python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py | sed -n '431,436p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 327


Use the network child for SSH shell connections instead of hardcoding 127.0.0.1.

The Connection(host="127.0.0.1") at lines 84-90 ignores the custom Hostfwd.hostaddr setting and fails for non-local clients. When the client runs on a different machine, it connects to the client's loopback instead of the server's configured host address. The proper path is to resolve into the ssh child (a TcpNetwork instance at line 489) which already has the correct hostaddr and port. The fallback pattern using FabricAdapter at lines 92-97 demonstrates the correct approach for tunneling through jumpstarter's stream.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py` at
line 7, The hardcoded Connection(host="127.0.0.1") usage in the SSH shell
connection block (around lines 84-90) ignores the custom Hostfwd.hostaddr
setting and breaks for non-local clients. Replace this hardcoded approach by
resolving into the ssh child which is a TcpNetwork instance at line 489 that
already contains the correct hostaddr and port configuration. Reference the
FabricAdapter fallback pattern at lines 92-97 as an example of the correct
approach for properly tunneling through jumpstarter's stream instead of creating
direct connections to 127.0.0.1.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from fabric import Connection is a new import added by this PR, but fabric is not listed in dependencies.

from jumpstarter_driver_composite.client import CompositeClient
from jumpstarter_driver_network.adapters import FabricAdapter, NovncAdapter

Expand Down Expand Up @@ -75,12 +76,25 @@ def novnc(self):

@contextmanager
def shell(self):
with FabricAdapter(
client=self.ssh,
user=self.username,
connect_kwargs={"password": self.password},
) as conn:
yield conn
# If the driver has an 'ssh' hostfwd entry, fetch the actual host port
# (resolving any port=0 assignment) and connect directly over TCP.
# Otherwise fall back to tunnelling through the jumpstarter stream (vsock).
try:

@mangelajo mangelajo Jun 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we had to implement this because alpine does not setup ssh over vhost, and we didn't want to pick a port that could potentially conflict with whatever is on the testing host.

port = int(self.call("get_hostfwd_port", "ssh"))
with Connection(
host="127.0.0.1",
port=port,
user=self.username,
connect_kwargs={"password": self.password},
) as conn:
yield conn
except KeyError:
Comment on lines +86 to +91

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay this one is a little backwards but hear me out: I think shell() method catches KeyError to fall back from tcp to vsock, but this never actually works. On the driver side, get_hostfwd_port() raises KeyError, but the gRPC transport catches all exceptions and reraises them as DriverError via StatusCode.UNKNOWN but DriverError doesn't inherit from KeyError so VMs without SSH hostfwd will crash with an unhandled DriverError instead of falling back to vsock.

with FabricAdapter(
client=self.ssh,
user=self.username,
connect_kwargs={"password": self.password},
) as conn:
yield conn
Comment on lines +82 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the file
find . -type f -name "client.py" | grep jumpstarter-driver-qemu

Repository: jumpstarter-dev/jumpstarter

Length of output: 147


🏁 Script executed:

# Read the client.py file to see the full context
cat -n "python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py" | head -150

Repository: jumpstarter-dev/jumpstarter

Length of output: 4941


🏁 Script executed:

# Check the imports to see if contextmanager is imported
head -20 "python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py"

Repository: jumpstarter-dev/jumpstarter

Length of output: 618


Narrow the try block to avoid catching caller KeyErrors in this context manager.

In a @contextmanager, exceptions from the caller's with body resume at the yield statement. A caller that raises KeyError inside the context will be caught by the current broad try block, triggering the fallback path unintentionally and potentially masking the original error. Move the except handler to wrap only the get_hostfwd_port call using try/except/else:

Proposed fix
+        try:
+            port = int(self.call("get_hostfwd_port", "ssh"))
+        except KeyError:
+            with FabricAdapter(
+                client=self.ssh,
+                user=self.username,
+                connect_kwargs={"password": self.password},
+            ) as conn:
+                yield conn
+        else:
             with Connection(
                 host="127.0.0.1",
                 port=port,
                 user=self.username,
                 connect_kwargs={"password": self.password},
             ) as conn:
                 yield conn
-        except KeyError:
-            with FabricAdapter(
-                client=self.ssh,
-                user=self.username,
-                connect_kwargs={"password": self.password},
-            ) as conn:
-                yield conn
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py`
around lines 82 - 97, The try/except block in the context manager is too broad
and will catch KeyError exceptions raised by the caller's code inside the with
block, causing unintended fallback behavior. Restructure this using
try/except/else pattern: wrap only the self.call("get_hostfwd_port", "ssh") call
in the try block, move the Connection creation and yield statement to an else
block (which executes only on success), and keep the FabricAdapter fallback in
the except KeyError block. This ensures that only KeyError from the
get_hostfwd_port call triggers the fallback, while caller errors are properly
propagated.

Comment on lines +86 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrow the try/except to only wrap the self.call("get_hostfwd_port", "ssh") call, then branch on the result.


def cli(self):
# Get the base group from CompositeClient which includes all child commands
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
import platform
import shlex
import shutil
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
Expand Down Expand Up @@ -381,6 +382,26 @@ async def on(self) -> None: # noqa: C901
Path(self.parent._pty).unlink(missing_ok=True)
Path(self.parent._pty).symlink_to(pty)

# Resolve any hostport=0 hostfwd entries to the actual port QEMU chose.
# Parse 'info usernet': lines look like "TCP[HOST_FORWARD] fd addr port addr port ..."
# Store resolved ports on the parent so get_hostfwd_port() can return them to clients.
zero_fwds = {k: v for k, v in self.parent.hostfwd.items() if v.hostport == 0}
if zero_fwds:
usernet = await qmp.execute("human-monitor-command", {"command-line": "info usernet"})
self.logger.debug("info usernet output:\n%s", usernet)
for line in usernet.splitlines():
parts = line.split()
if len(parts) >= 6 and "HOST_FORWARD" in parts[0]:
# parts: Protocol[State] fd hostaddr hostport guestaddr guestport ...
actual_hostaddr, actual_hostport, actual_guestport = parts[2], int(parts[3]), int(parts[5])
for k, v in zero_fwds.items():
if v.hostaddr == actual_hostaddr and v.guestport == actual_guestport:
self.logger.info(
"hostfwd '%s': resolved port 0 -> %s:%d (guest port %d)",
k, actual_hostaddr, actual_hostport, actual_guestport,
)
self.parent._resolved_hostports[k] = actual_hostport
Comment on lines +385 to +403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "driver.py" | grep jumpstarter-driver-qemu

Repository: jumpstarter-dev/jumpstarter

Length of output: 147


🏁 Script executed:

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

Repository: jumpstarter-dev/jumpstarter

Length of output: 149


🏁 Script executed:

sed -n '380,410p' python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 1849


🏁 Script executed:

sed -n '560,580p' python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 765


🏁 Script executed:

sed -n '430,440p' python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 382


🏁 Script executed:

sed -n '460,470p' python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 553


🏁 Script executed:

rg "_resolved_hostports" python/packages/jumpstarter-driver-qemu/

Repository: jumpstarter-dev/jumpstarter

Length of output: 623


🏁 Script executed:

rg "get_hostfwd_port" python/packages/jumpstarter-driver-qemu/

Repository: jumpstarter-dev/jumpstarter

Length of output: 498


🏁 Script executed:

rg "get_hostfwd_port" --type py

Repository: jumpstarter-dev/jumpstarter

Length of output: 498


Fail fast when a dynamic hostfwd port is unresolved.

Currently, if info usernet parsing misses a hostport=0 entry, get_hostfwd_port() returns 0 to the caller, causing SSH and other services to fail silently on an invalid port. Validate that every zero-port entry resolves during VM startup, and explicitly reject unresolved ports in get_hostfwd_port().

The proposed fix is necessary and should be applied:

  • Accumulate resolved ports in a local dict and validate all zero_fwds entries are resolved; raise RuntimeError with the list of missing keys if any are unresolved.
  • In get_hostfwd_port(), check if hostport == 0 and raise RuntimeError instead of returning the invalid port.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py`
around lines 385 - 403, After the for loop that processes usernet lines in the
hostfwd resolution block, add validation to ensure all zero-port entries were
successfully resolved. Create a set of unresolved ports by checking which
entries in zero_fwds are not present in self.parent._resolved_hostports, and
raise a RuntimeError with the list of missing keys if any are found.
Additionally, in the get_hostfwd_port() method (not shown in this diff), add a
check that raises RuntimeError if the hostport value equals 0, preventing silent
failures when unresolved ports are returned to callers.

Comment on lines +392 to +403

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The info usernet parser uses int(parts[3]) and int(parts[5]) without try/except. A malformed line would raise `ValueError


await qmp.execute("system_reset")
await qmp.disconnect()

Comment on lines 382 to 407

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ValueError from malformed output or a QMP error leaves the connection open and the QEMU process orphaned. You could wrap the QMP operation block in try/finally to guarantee await qmp.disconnect() runs.

Expand Down Expand Up @@ -410,7 +431,7 @@ def close(self):
class Hostfwd(BaseModel):
protocol: Literal["tcp"] = "tcp"
hostaddr: str = "127.0.0.1"
hostport: int = Field(ge=1, le=65535)
hostport: int = Field(ge=0, le=65535) # 0 = let QEMU pick a free port

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] hostaddr: str = "127.0.0.1" has no format validation. The value is interpolated unsanitized into a comma-separated QEMU -netdev argument. Since QEMU uses commas to delimit netdev options, a crafted hostaddr like 127.0.0.1,hostfwd=tcp:0.0.0.0:4444-:4444 would inject an additional hostfwd rule. The hostport and guestport fields have Field(ge=..., le=...) constraints, but hostaddr accepts arbitrary strings.

Suggested fix: add a Pydantic field_validator using ipaddress.ip_address() to ensure hostaddr is a valid IP address.

AI-generated, human reviewed

guestport: int = Field(ge=1, le=65535)


Comment on lines 431 to 437

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If on() fails after process creation but before usernet resolution completes, get_hostfwd_port() returns a stale port. I think adding self.parent._resolved_hostports.clear() at the beginning of on() before starting the new QEMU process, and also in off() alongside the existing cleanup should resolve this.

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

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

@classmethod
def client(cls) -> str:
Comment on lines 461 to 468

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defer TcpNetwork child creation for hostport=0 entries until after port resolution in on(), or update the child's port after resolution. Use a different key namespace for hostfwd children to avoid overwriting vsock children.

Expand Down Expand Up @@ -512,6 +535,7 @@ def cidata(self) -> TemporaryDirectory:
{
"instance-id": str(self.uuid),
"local-hostname": self.hostname,
"hostname": self.hostname,
}
)
)
Expand All @@ -528,12 +552,30 @@ def cidata(self) -> TemporaryDirectory:
"sudo": "ALL=(ALL) NOPASSWD:ALL",
}
],
# runcmd sets the password explicitly for cloud-init implementations
# that do not support plain_text_passwd (e.g. Alpine's tiny-cloud).
# cloud-init ignores runcmd entries it doesn't understand, so this
# is safe to include unconditionally.
# shlex.quote ensures special characters in credentials are safe.
"runcmd": [
f"printf %s {shlex.quote(f'{self.username}:{self.password}')} | chpasswd",
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
)
)

return tmp

@export
@validate_call(validate_return=True)
def get_hostfwd_port(self, key: str) -> int:
"""Return the actual host port for a hostfwd entry (resolves port 0 assignments)."""
if key in self._resolved_hostports:
return self._resolved_hostports[key]
if key in self.hostfwd:
return self.hostfwd[key].hostport
raise KeyError(f"hostfwd key {key!r} not found")
Comment on lines +571 to +577

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] get_hostfwd_port() is a new exported method with three code paths (resolved port, configured port, KeyError), and the info usernet parsing logic uses positional string splitting with int(parts[3]) and int(parts[5]). Neither has any unit test. The only coverage is the full integration test that boots a real VM. Extracting the parsing logic into a standalone function (e.g., _parse_usernet_hostfwd(usernet_output, zero_fwds)) would make it straightforward to add parameterized unit tests covering all three get_hostfwd_port() code paths as well as edge cases in the usernet output format.

AI-generated, human reviewed


@export
@validate_call(validate_return=True)
def get_hostname(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,31 @@ def get_native_arch_config():
def test_driver_qemu(tmp_path, ovmf):
arch, ovmf_arch = get_native_arch_config()

# Alpine uses OpenRC (not systemd), so systemd-ssh-generator does not run
# and sshd never binds to AF_VSOCK. Use a TCP hostfwd with hostport=0 so
# QEMU picks a free port automatically; the driver resolves the actual port
# from QMP after startup and updates the ssh child accordingly.
with serve(
Qemu(
arch=arch,
default_partitions={
"OVMF_CODE.fd": ovmf / ovmf_arch / "code.fd",
"OVMF_VARS.fd": ovmf / ovmf_arch / "vars.fd",
},
hostfwd={"ssh": {"protocol": "tcp", "hostaddr": "127.0.0.1", "hostport": 0, "guestport": 22}},
)
) as qemu:
hostname = qemu.hostname
username = qemu.username
password = qemu.password

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

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

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

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

with qemu.shell() as s:
assert s.run("uname -r").stdout.strip() == f"6.17.1-300.fc43.{arch}"
assert s.run("uname -r").stdout.strip() != ""
Comment on lines 110 to +111

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also assert the return code, e.g., result = s.run("uname -r"); assert result.ok; assert result.stdout.strip() != "".


qemu.power.off()

Expand Down
Loading