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

Commit d1edbe0

Browse files
authored
Merge branch 'main' into fix-index-generation-workflow
2 parents 1681555 + 58a133e commit d1edbe0

10 files changed

Lines changed: 543 additions & 23 deletions

File tree

.github/workflows/build.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ on:
55
branches:
66
- main
77
- release-*
8+
tags:
9+
- v*
810
merge_group:
911

1012
env:

docs/multiversion.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bash
22
set -euox pipefail
33

4-
declare -a BRANCHES=("main" "release-0.5" "release-0.6")
4+
declare -a BRANCHES=("main" "release-0.5" "release-0.6" "release-0.7")
55

66
# https://stackoverflow.com/a/246128
77
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

packages/jumpstarter-cli/jumpstarter_cli/run.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,34 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15+
def _handle_exporter_exceptions(excgroup):
16+
"""Handle exceptions from exporter serving."""
17+
from jumpstarter_cli_common.exceptions import leaf_exceptions
18+
for exc in leaf_exceptions(excgroup):
19+
if not isinstance(exc, anyio.get_cancelled_exc_class()):
20+
click.echo(
21+
f"Exception while serving on the exporter: {type(exc).__name__}: {exc}",
22+
err=True,
23+
)
24+
25+
26+
def _reap_zombie_processes(capture_child=None):
27+
"""Reap zombie processes when running as PID 1."""
28+
try:
29+
while True:
30+
try:
31+
pid, status = os.waitpid(-1, os.WNOHANG)
32+
if pid == 0:
33+
break # No more children
34+
if capture_child and pid == capture_child['pid']:
35+
capture_child['status'] = status
36+
logger.debug(f"PARENT: Reaped zombie process {pid} with status {status}")
37+
except ChildProcessError:
38+
break # No more children
39+
except Exception as e:
40+
logger.warning(f"PARENT: Error during zombie reaping: {e}")
41+
42+
1543
def _handle_child(config):
1644
"""Handle child process with graceful shutdown."""
1745
async def serve_with_graceful_shutdown():
@@ -28,6 +56,7 @@ async def signal_handler():
2856
continue # Ignore duplicate signals
2957
received_signal = sig
3058
logger.info("CHILD: Received %d (%s)", received_signal, signal.Signals(received_signal).name)
59+
3160
if exporter:
3261
# Terminate exporter. SIGHUP waits until current lease is let go. Later SIGTERM still overrides
3362
if received_signal != signal.SIGHUP:
@@ -45,13 +74,7 @@ async def signal_handler():
4574
try:
4675
await exporter.serve()
4776
except* Exception as excgroup:
48-
from jumpstarter_cli_common.exceptions import leaf_exceptions
49-
for exc in leaf_exceptions(excgroup):
50-
if not isinstance(exc, anyio.get_cancelled_exc_class()):
51-
click.echo(
52-
f"Exception while serving on the exporter: {type(exc).__name__}: {exc}",
53-
err=True,
54-
)
77+
_handle_exporter_exceptions(excgroup)
5578

5679
# Cancel the signal handler after exporter completes
5780
signal_tg.cancel_scope.cancel()
@@ -62,21 +85,38 @@ async def signal_handler():
6285
sys.exit(anyio.run(serve_with_graceful_shutdown))
6386

6487

88+
def _wait_for_child(pid, child_info):
89+
"""Wait for child process, get status from signal handler if reaped."""
90+
try:
91+
_, status = os.waitpid(pid, 0)
92+
except ChildProcessError:
93+
status = child_info['status']
94+
return status
95+
96+
6597
def _handle_parent(pid):
6698
"""Handle parent process waiting for child and signal forwarding."""
99+
child_info = {'pid': pid, 'status': None}
100+
67101
def parent_signal_handler(signum, _):
68-
logger.info("PARENT: Received %d (%s), forwarding to child PID %d", signum, signal.Signals(signum).name, pid)
69-
if pid and pid > 0:
70-
try:
71-
os.kill(pid, signum)
72-
except ProcessLookupError:
73-
pass
102+
if signum == signal.SIGCHLD and os.getpid() == 1:
103+
_reap_zombie_processes(capture_child=child_info) # capture our own direct child if reaped
104+
elif signum != signal.SIGCHLD:
105+
logger.info("PARENT: Got %d (%s), forwarding to child PG %d", signum, signal.Signals(signum).name, pid)
106+
if pid > 0:
107+
try:
108+
os.killpg(pid, signum)
109+
except (ProcessLookupError, OSError):
110+
pass
74111

75112
# Set up signal handlers after fork
76-
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT):
113+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT, signal.SIGCHLD):
77114
signal.signal(sig, parent_signal_handler)
78115

79-
_, status = os.waitpid(pid, 0)
116+
status = _wait_for_child(pid, child_info)
117+
if status is None:
118+
return None
119+
80120
if os.WIFEXITED(status):
81121
# Interpret child exit code
82122
child_exit_code = os.WEXITSTATUS(status)
@@ -100,6 +140,7 @@ def _serve_with_exc_handling(config):
100140
if (exit_code := _handle_parent(pid)) is not None:
101141
return exit_code
102142
else:
143+
os.setsid() # Become group leader so all spawned subprocesses are reached by parent's signals
103144
_handle_child(config)
104145
sys.exit(1) # should never happen
105146

packages/jumpstarter-driver-network/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Network drivers
22

33
`jumpstarter-driver-network` provides functionality for interacting with network
4-
servers and connections.
4+
servers and connections, redirecting DUT network services to the client handling
5+
the lease.
56

67
## Installation
78

@@ -19,9 +20,19 @@ export:
1920
network:
2021
type: jumpstarter_driver_network.driver.TcpNetwork
2122
config:
22-
# Add required parameters here
23+
host: 192.168.1.2
24+
port: 5201
25+
enable_address: true
2326
```
2427
28+
### Config parameters
29+
30+
| Parameter | Description | Type | Required | Default |
31+
| ------------- | --------------------------------------------------- | ----- | -------- | ------------------ |
32+
| host | Hostname or IP address of the DUT | str | yes | |
33+
| port | Port number of the DUT service to connect to | int | yes | |
34+
| enable_address | Whether to enable address mode (reporting the address of the client) | bool | no | true |
35+
2536
## API Reference
2637
2738
Network driver classes:

packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22
from contextlib import contextmanager
33
from ipaddress import IPv6Address, ip_address
44
from threading import Event
5-
from typing import Any
5+
from typing import Any, Tuple
6+
from urllib.parse import urlparse
67

78
import click
89
from anyio import ContextManagerMixin
910

1011
from .adapters import DbusAdapter, TcpPortforwardAdapter, UnixPortforwardAdapter
1112
from .driver import DbusNetwork
1213
from jumpstarter.client import DriverClient
14+
from jumpstarter.client.core import DriverMethodNotImplemented
1315

1416

1517
class NetworkClient(DriverClient):
18+
19+
def address(self):
20+
return self.call("address")
21+
1622
def cli(self):
1723
@click.group
1824
def base():
@@ -62,9 +68,35 @@ def forward_unix(path: str | None):
6268

6369
Event().wait()
6470

65-
return base
71+
@base.command()
72+
@click.option("--host", is_flag=True)
73+
@click.option("--port", is_flag=True)
74+
def address(host, port):
75+
"""
76+
Direct TCP connection to remote network
77+
"""
78+
try:
79+
addr = self.address()
80+
if not host and not port or host and port:
81+
# Strip any URL scheme for clean display
82+
clean_addr = _strip_scheme(addr)
83+
click.echo(clean_addr)
84+
else:
85+
# Parse address safely to handle IPv6
86+
parsed_host, parsed_port = _parse_address(addr)
87+
click.echo(parsed_host if host else parsed_port)
88+
except ValueError as e:
89+
raise click.ClickException(
90+
f"enable_address mode is not true in the exporter configuration: {e}"
91+
) from e
92+
except DriverMethodNotImplemented as e:
93+
raise click.ClickException(
94+
"This exporter does not support direct connection yet, update exporter to 0.7.1 or later"
95+
) from e
6696

6797

98+
return base
99+
68100
class DbusNetworkClient(NetworkClient, ContextManagerMixin):
69101
@contextmanager
70102
def __contextmanager__(self) -> Generator[Any]:
@@ -74,3 +106,57 @@ def __contextmanager__(self) -> Generator[Any]:
74106
@property
75107
def kind(self):
76108
return self.labels[DbusNetwork.KIND_LABEL]
109+
110+
111+
def _parse_address(addr: str) -> Tuple[str, str]:
112+
"""Parse a host:port address string, handling IPv6 addresses correctly.
113+
114+
Uses urllib.parse.urlparse for robust parsing of network addresses.
115+
116+
Returns:
117+
Tuple of (host, port) as strings
118+
119+
Examples:
120+
"127.0.0.1:8080" -> ("127.0.0.1", "8080")
121+
"[::1]:8080" -> ("::1", "8080")
122+
"localhost:8080" -> ("localhost", "8080")
123+
"""
124+
# Add a dummy scheme to make it a valid URL for urlparse
125+
if not addr.startswith(("http://", "https://", "tcp://", "udp://")):
126+
addr = f"tcp://{addr}"
127+
128+
parsed = urlparse(addr)
129+
host = parsed.hostname or ""
130+
port = str(parsed.port) if parsed.port else ""
131+
132+
return host, port
133+
134+
135+
def _strip_scheme(addr: str) -> str:
136+
"""Remove URL scheme from address string for clean display.
137+
138+
Uses urllib.parse.urlparse to properly handle various URL formats.
139+
140+
Returns:
141+
Address string without scheme prefix
142+
143+
Examples:
144+
"tcp://127.0.0.1:8080" -> "127.0.0.1:8080"
145+
"udp://[::1]:8080" -> "[::1]:8080"
146+
"127.0.0.1:8080" -> "127.0.0.1:8080"
147+
"""
148+
# Handle IPv6 addresses in brackets specially
149+
if "://[" in addr and "]" in addr:
150+
# Find the scheme separator and the closing bracket
151+
scheme_end = addr.find("://")
152+
if scheme_end != -1:
153+
# Extract everything after "://"
154+
return addr[scheme_end + 3:]
155+
156+
# For other cases, use urlparse
157+
parsed = urlparse(addr)
158+
# Reconstruct the address without scheme
159+
if parsed.port:
160+
return f"{parsed.hostname}:{parsed.port}"
161+
else:
162+
return parsed.hostname or addr

0 commit comments

Comments
 (0)