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

Commit 51d3455

Browse files
committed
Merge branch 'main' into driver-cli-integration
2 parents 6a9eb05 + 955baef commit 51d3455

11 files changed

Lines changed: 162 additions & 43 deletions

File tree

.github/workflows/pytest.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ jobs:
6262
done
6363
6464
- name: Run pytest
65-
run: make test
65+
run: |
66+
export UV_PYTHON=${{ matrix.python-version }}
67+
make test
68+
6669
6770
# https://github.com/orgs/community/discussions/26822
6871
pytest:

packages/jumpstarter-cli/jumpstarter_cli/shell.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,25 @@
1212

1313
@click.command("shell")
1414
@opt_config()
15+
@click.argument("command", nargs=-1)
1516
# client specific
1617
# TODO: warn if these are specified with exporter config
1718
@click.option("--lease", "lease_name")
1819
@opt_selector
1920
@opt_duration_partial(default=timedelta(minutes=30), show_default="00:30:00")
2021
# end client specific
2122
@handle_exceptions
22-
def shell(config, lease_name, selector, duration):
23+
def shell(config, command: tuple[str, ...], lease_name, selector, duration):
2324
"""
24-
Spawns a shell connecting to a local or remote exporter
25+
Spawns a shell (or custom command) connecting to a local or remote exporter
26+
27+
COMMAND is the custom command to run instead of shell.
28+
29+
Example:
30+
31+
.. code-block:: bash
32+
33+
$ jmp shell --exporter foo -- python bar.py
2534
"""
2635

2736
match config:
@@ -31,11 +40,23 @@ def shell(config, lease_name, selector, duration):
3140
with config.lease(selector=selector, lease_name=lease_name, duration=duration) as lease:
3241
with lease.serve_unix() as path:
3342
with lease.monitor():
34-
exit_code = launch_shell(path, "remote", config.drivers.allow, config.drivers.unsafe)
43+
exit_code = launch_shell(
44+
path,
45+
"remote",
46+
config.drivers.allow,
47+
config.drivers.unsafe,
48+
command=command,
49+
)
3550

3651
sys.exit(exit_code)
3752

3853
case ExporterConfigV1Alpha1():
3954
with config.serve_unix() as path:
4055
# SAFETY: the exporter config is local thus considered trusted
41-
launch_shell(path, "local", allow=[], unsafe=True)
56+
launch_shell(
57+
path,
58+
"local",
59+
allow=[],
60+
unsafe=True,
61+
command=command,
62+
)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import logging
45
import os
56
import platform
67
from collections.abc import AsyncGenerator
@@ -25,6 +26,11 @@
2526
from jumpstarter.driver import Driver, export
2627

2728

29+
class QmpLogFilter(logging.Filter):
30+
def filter(self, record):
31+
return False
32+
33+
2834
@dataclass(kw_only=True)
2935
class QemuFlasher(FlasherInterface, Driver):
3036
parent: Qemu
@@ -50,6 +56,10 @@ class QemuPower(PowerInterface, Driver):
5056

5157
@export
5258
async def on(self) -> None: # noqa: C901
59+
if hasattr(self, "_process"):
60+
self.logger.warning("already powered on, ignoring request")
61+
return
62+
5363
root = self.parent.validate_partition("root")
5464
bios = self.parent.validate_partition("bios")
5565
ovmf_code = self.parent.validate_partition("OVMF_CODE.fd")
@@ -165,6 +175,10 @@ async def on(self) -> None: # noqa: C901
165175

166176
qmp = QMPClient(self.parent.hostname)
167177

178+
logging.getLogger(
179+
"qemu.qmp.protocol.{}".format(self.parent.hostname),
180+
).addFilter(QmpLogFilter())
181+
168182
with fail_after(10):
169183
while qmp.runstate != Runstate.RUNNING:
170184
try:
@@ -174,6 +188,7 @@ async def on(self) -> None: # noqa: C901
174188

175189
chardevs = await qmp.execute("query-chardev")
176190
pty = next(c for c in chardevs if c["label"] == "serial0")["filename"].lstrip("pty:")
191+
Path(self.parent._pty).unlink(missing_ok=True)
177192
Path(self.parent._pty).symlink_to(pty)
178193

179194
await qmp.execute("system_reset")
@@ -188,6 +203,8 @@ def off(self) -> None:
188203
except TimeoutExpired:
189204
self._process.kill()
190205
del self._process
206+
else:
207+
self.logger.warning("already powered off, ignoring request")
191208

192209
if hasattr(self, "_cidata"):
193210
del self._cidata

packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ def call_method(self, method: str, env, *args):
3838
if result.returncode != 0:
3939
self.logger.info(f"{method} return code: {result.returncode}")
4040
if result.stderr != "":
41-
self.logger.debug(f"{method} stderr:\n{result.stderr.rstrip('\n')}")
41+
stderr = result.stderr.rstrip("\n")
42+
self.logger.debug(f"{method} stderr:\n{stderr}")
4243
if result.stdout != "":
43-
self.logger.debug(f"{method} stdout:\n{result.stdout.rstrip('\n')}")
44+
stdout = result.stdout.rstrip("\n")
45+
self.logger.debug(f"{method} stdout:\n{stdout}")
4446
return result.stdout, result.stderr, result.returncode
4547
except subprocess.TimeoutExpired as e:
4648
self.logger.error(f"Timeout expired while running {method}: {e}")

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,20 @@ async def monitor_async(self, threshold: timedelta = timedelta(minutes=5)):
163163
async def _monitor():
164164
while True:
165165
lease = await self.get()
166+
# TODO: use effective_end_time as the authoritative source for lease end time
166167
if lease.effective_begin_time:
167168
end_time = lease.effective_begin_time + lease.duration
168169
remain = end_time - datetime.now(tz=datetime.now().astimezone().tzinfo)
169-
if remain < threshold:
170+
if remain < timedelta(0):
171+
# lease already expired, stopping monitor
172+
logger.info("Lease {} ended at {}".format(self.name, end_time))
173+
break
174+
elif remain < threshold:
175+
# lease expiring soon, check again on expected expiration time in case it's extended
170176
logger.info("Lease {} ending soon in {} at {}".format(self.name, remain, end_time))
171177
await sleep(threshold.total_seconds())
172178
else:
179+
# lease still active, check again in 5 seconds
173180
await sleep(5)
174181
else:
175182
await sleep(1)

packages/jumpstarter/jumpstarter/common/grpc.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import base64
23
import os
34
import socket
@@ -7,11 +8,12 @@
78
from urllib.parse import urlparse
89

910
import grpc
11+
from anyio import fail_after
1012

1113
from jumpstarter.common.exceptions import ConfigurationError, ConnectionError
1214

1315

14-
def ssl_channel_credentials(target: str, tls_config):
16+
async def ssl_channel_credentials(target: str, tls_config, timeout=5):
1517
configure_grpc_env()
1618
if tls_config.insecure or os.getenv("JUMPSTARTER_GRPC_INSECURE") == "1":
1719
try:
@@ -21,12 +23,21 @@ def ssl_channel_credentials(target: str, tls_config):
2123
raise ConfigurationError(f"Failed parsing {target}") from e
2224

2325
try:
24-
root_certificates = ssl.get_server_certificate((parsed.hostname, port))
26+
with fail_after(timeout):
27+
ssl_context = ssl.create_default_context()
28+
ssl_context.check_hostname = False
29+
ssl_context.verify_mode = ssl.CERT_NONE
30+
_, writer = await asyncio.open_connection(parsed.hostname, port, ssl=ssl_context)
31+
root_certificates = ""
32+
for cert in writer.get_extra_info("ssl_object")._sslobj.get_unverified_chain():
33+
root_certificates += cert.public_bytes()
2534
return grpc.ssl_channel_credentials(root_certificates=root_certificates.encode())
2635
except socket.gaierror as e:
2736
raise ConnectionError(f"Failed resolving {parsed.hostname}") from e
2837
except ConnectionRefusedError as e:
2938
raise ConnectionError(f"Failed connecting to {parsed.hostname}:{port}") from e
39+
except TimeoutError as e:
40+
raise ConnectionError(f"Timeout connecting to {parsed.hostname}:{port}") from e
3041

3142
elif tls_config.ca != "":
3243
ca_certificate = base64.b64decode(tls_config.ca)

packages/jumpstarter/jumpstarter/common/streams.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class StreamRequestMetadata(BaseModel):
3434
@asynccontextmanager
3535
async def connect_router_stream(endpoint, token, stream, tls_config, grpc_options):
3636
credentials = grpc.composite_channel_credentials(
37-
ssl_channel_credentials(endpoint, tls_config),
37+
await ssl_channel_credentials(endpoint, tls_config),
3838
grpc.access_token_call_credentials(token),
3939
)
4040

packages/jumpstarter/jumpstarter/common/utils.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ def env():
8080
PROMPT_CWD = "\\W"
8181

8282

83-
def launch_shell(host: str, context: str, allow: list[str], unsafe: bool) -> int:
83+
def launch_shell(
84+
host: str,
85+
context: str,
86+
allow: [str],
87+
unsafe: bool,
88+
*,
89+
command: tuple[str, ...] | None = None,
90+
) -> int:
8491
"""Launch a shell with a custom prompt indicating the exporter type.
8592
8693
Args:
@@ -89,21 +96,21 @@ def launch_shell(host: str, context: str, allow: list[str], unsafe: bool) -> int
8996
allow: List of allowed drivers
9097
unsafe: Whether to allow drivers outside of the allow list
9198
"""
92-
cmd = [os.environ.get("SHELL", "bash")]
93-
if cmd[0].endswith("bash"):
94-
cmd.append("--norc")
95-
cmd.append("--noprofile")
96-
97-
process = Popen(
98-
cmd,
99-
stdin=sys.stdin,
100-
stdout=sys.stdout,
101-
stderr=sys.stderr,
102-
env=os.environ
103-
| {
104-
JUMPSTARTER_HOST: host,
105-
JMP_DRIVERS_ALLOW: "UNSAFE" if unsafe else ",".join(allow),
106-
"PS1": f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}{ANSI_WHITE}{context} {ANSI_YELLOW}{ANSI_RESET} ",
107-
},
108-
)
99+
100+
env = os.environ | {
101+
JUMPSTARTER_HOST: host,
102+
JMP_DRIVERS_ALLOW: "UNSAFE" if unsafe else ",".join(allow),
103+
"PS1": f"{ANSI_GRAY}{PROMPT_CWD} {ANSI_YELLOW}{ANSI_WHITE}{context} {ANSI_YELLOW}{ANSI_RESET} ",
104+
}
105+
106+
if command:
107+
process = Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
108+
else:
109+
cmd = [os.environ.get("SHELL", "bash")]
110+
if cmd[0].endswith("bash"):
111+
cmd.append("--norc")
112+
cmd.append("--noprofile")
113+
114+
process = Popen(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, env=env)
115+
109116
return process.wait()

packages/jumpstarter/jumpstarter/config/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class ClientConfigV1Alpha1(BaseModel):
5454

5555
async def channel(self):
5656
credentials = grpc.composite_channel_credentials(
57-
ssl_channel_credentials(self.endpoint, self.tls),
57+
await ssl_channel_credentials(self.endpoint, self.tls),
5858
call_credentials("Client", self.metadata, self.token),
5959
)
6060

packages/jumpstarter/jumpstarter/config/exporter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ async def serve(self):
159159
# dynamic import to avoid circular imports
160160
from jumpstarter.exporter import Exporter
161161

162-
def channel_factory():
162+
async def channel_factory():
163163
credentials = grpc.composite_channel_credentials(
164-
ssl_channel_credentials(self.endpoint, self.tls),
164+
await ssl_channel_credentials(self.endpoint, self.tls),
165165
call_credentials("Exporter", self.metadata, self.token),
166166
)
167167
return aio_secure_channel(self.endpoint, credentials, self.grpcOptions)

0 commit comments

Comments
 (0)