Skip to content

Commit e6a7540

Browse files
fix: address PR #642 review follow-ups for dut-network driver (#653)
Addresses review feedback from @bennyz and @raballew on #642: - **dnsmasq.stop() missing state_dir**: Pass `state_dir=self._state_path` so the pidfile is used as the authoritative PID source, matching the pattern already used by `reload_config()` - **NAT probe at import time**: Defer `_can_nat_between_namespaces()` from module-level execution to a session-scoped `nat_available` pytest fixture, avoiding namespace/veth/nft side effects during `pytest --collect-only` or IDE test discovery - **shell=True in test helpers**: Refactor `_run()` and `_popen()` from string-based `shell=True` to list-based subprocess via `shlex.split()`, consistent with production code - **Broken README diagram**: Replace the misaligned hardware diagram with architecture diagrams showing internal data flow (interface → dnsmasq → ip_fwd → nftables → upstream) for both NAT and DHCP-only modes Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent abd5d91 commit e6a7540

3 files changed

Lines changed: 75 additions & 29 deletions

File tree

python/packages/jumpstarter-driver-dut-network/README.md

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export:
4343
dut-network:
4444
type: jumpstarter_driver_dut_network.driver.DutNetwork
4545
config:
46-
interface: "enx00e04c683af1"
46+
interface: "eth2"
4747
subnet: "192.168.100.0/24"
4848
gateway_ip: "192.168.100.1"
4949
nat_mode: "masquerade"
@@ -66,7 +66,7 @@ export:
6666
dut-network:
6767
type: jumpstarter_driver_dut_network.driver.DutNetwork
6868
config:
69-
interface: "enx00e04c683af1"
69+
interface: "eth2"
7070
subnet: "192.168.100.0/24"
7171
gateway_ip: "192.168.100.1"
7272
upstream_interface: "enp2s0"
@@ -105,7 +105,7 @@ export:
105105
dut-network:
106106
type: jumpstarter_driver_dut_network.driver.DutNetwork
107107
config:
108-
interface: "enx00e04c683af1"
108+
interface: "eth2"
109109
nat_mode: "masquerade"
110110
dns_entries:
111111
- hostname: "controller.lab.local"
@@ -206,15 +206,47 @@ with env() as client:
206206

207207
The driver uses a dedicated nftables table (named after the interface, e.g. `table ip jumpstarter_enx00e04c683af1`) that does not conflict with firewalld or other nftables users. Firewalld manages its own `firewalld` table and does not touch other tables, even during reloads.
208208

209-
## Typical Hardware Setup
209+
## Architecture
210+
211+
```text
212+
Exporter Host
213+
┌─────────┐ ┌──────────────────────────────────────┐ ┌─────────┐
214+
│ DUT │ │ │ │ LAN │
215+
│ │ eth │ eth2 ┌──────────┐ │ │ │
216+
│ DHCP │◄──────►│ 192.168.100.1/24 │ dnsmasq │ │ │ │
217+
│ client │ │ (gateway) │ DHCP+DNS │ │ │ │
218+
│ │ │ │ └──────────┘ │ │ │
219+
│ 192.168.│ │ │ forwarding │ eth │ │
220+
│ 100.10 │ │ ▼ ┌──────────┐ │ │ │
221+
│ │ │ ┌─────────┐ │ nftables │ │ enp2s0 │ 10.26. │
222+
└─────────┘ │ │ ip_fwd │───────►│ NAT │────►│◄──────► │ 28.0/24 │
223+
│ └─────────┘ │ │ │(upstream)│ │
224+
│ │masq/1:1 │ │ └─────────┘
225+
│ └──────────┘ │
226+
└──────────────────────────────────────┘
227+
228+
─── Masquerade: DUT traffic appears as exporter's upstream IP
229+
─── 1:1 NAT: DUT gets a dedicated public IP on the upstream interface
230+
```
231+
232+
### Disabled NAT (DHCP-only isolation)
210233

211234
```text
212-
┌──────────────┐ Ethernet ┌──────────────────┐ LAN
213-
│ DUT │────────────────────│ Exporter Host │────────────
214-
│ (SA8775P) │ (USB NIC) │ (Sidekick) │ (enp2s0)
215-
│ end0: │ │ enx00e04c683af1: │
216-
│ 192.168.100.10 │ 192.168.100.1 │
217-
└──────────────┘ └──────────────────┘
235+
Exporter Host
236+
┌─────────┐ ┌──────────────────────────────┐
237+
│ DUT │ │ │
238+
│ │ eth │ eth2 ┌──────────┐ │
239+
│ DHCP │◄──────►│ 192.168.100.1 │ dnsmasq │ │
240+
│ client │ │ (gateway) │ DHCP+DNS │ │
241+
│ │ │ └──────────┘ │
242+
│ 192.168.│ │ │
243+
│ 100.10 │ │ No forwarding, no NAT. │
244+
│ │ │ L2-isolated network only. │
245+
└─────────┘ └──────────────────────────────┘
246+
247+
The DUT can reach the exporter on 192.168.100.1 but has
248+
no route to the LAN or internet. Useful for pure L2
249+
isolation or when routing is handled externally.
218250
```
219251

220252
## Troubleshooting
@@ -250,10 +282,10 @@ sysctl net.ipv4.conf.<upstream>.forwarding
250282

251283
## Running Tests
252284

253-
Integration tests require root privileges:
285+
Integration tests require root privileges through passwordless sudo, or direct root access:
254286

255287
```shell
256-
sudo make pkg-test-dut-network
288+
make pkg-test-dut-network
257289
```
258290

259291
Tests use veth pairs and network namespaces to simulate the DUT without real hardware.

python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/driver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def cleanup(self) -> None:
184184
self.logger.info("Cleaning up DUT network configuration")
185185

186186
if self._dnsmasq_process:
187-
dnsmasq.stop(process=self._dnsmasq_process)
187+
dnsmasq.stop(process=self._dnsmasq_process, state_dir=self._state_path)
188188
self._dnsmasq_process = None
189189

190190
nftables.flush_rules(self._table_name)

python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/driver_test.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88
import re
9+
import shlex
910
import shutil
1011
import subprocess
1112
import sys
@@ -27,25 +28,27 @@
2728
requires_dig = pytest.mark.skipif(not shutil.which("dig"), reason="dig not found")
2829
requires_dhclient = pytest.mark.skipif(not shutil.which("dhclient"), reason="dhclient not found")
2930

30-
_SUDO_PREFIX = "" if os.getuid() == 0 else "sudo "
31+
_SUDO_CMD: list[str] = ["sudo"] if os.getuid() != 0 else []
3132

3233

3334
def _run(cmd: str, check: bool = True, ns: str | None = None) -> subprocess.CompletedProcess:
34-
"""Run a shell command with sudo when not root, optionally inside a network namespace."""
35+
"""Run a command with sudo when not root, optionally inside a network namespace."""
36+
args = shlex.split(cmd)
3537
if ns:
36-
cmd = f"{_SUDO_PREFIX}ip netns exec {ns} {cmd}"
38+
args = [*_SUDO_CMD, "ip", "netns", "exec", ns, *args]
3739
else:
38-
cmd = f"{_SUDO_PREFIX}{cmd}"
39-
return subprocess.run(cmd, shell=True, capture_output=True, text=True, check=check)
40+
args = [*_SUDO_CMD, *args]
41+
return subprocess.run(args, capture_output=True, text=True, check=check)
4042

4143

4244
def _popen(cmd: str, ns: str | None = None) -> subprocess.Popen:
4345
"""Start a background process, optionally inside a network namespace."""
46+
args = shlex.split(cmd)
4447
if ns:
45-
cmd = f"{_SUDO_PREFIX}ip netns exec {ns} {cmd}"
48+
args = [*_SUDO_CMD, "ip", "netns", "exec", ns, *args]
4649
else:
47-
cmd = f"{_SUDO_PREFIX}{cmd}"
48-
return subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
50+
args = [*_SUDO_CMD, *args]
51+
return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
4952

5053

5154
def _can_nat_between_namespaces() -> bool:
@@ -144,10 +147,21 @@ def _can_nat_between_namespaces() -> bool:
144147
_run(f"ip netns del {dst_ns}", check=False)
145148

146149

147-
_nat_works = _can_nat_between_namespaces()
148-
requires_nat = pytest.mark.skipif(
149-
not _nat_works, reason="nftables NAT between namespaces not available"
150-
)
150+
_nat_works: bool | None = None
151+
152+
153+
def _nat_probe() -> bool:
154+
global _nat_works
155+
if _nat_works is None:
156+
_nat_works = _can_nat_between_namespaces()
157+
return bool(_nat_works)
158+
159+
160+
@pytest.fixture(scope="session")
161+
def nat_available() -> None:
162+
"""Run the NAT probe once per session (at test time, not import time)."""
163+
if not _nat_probe():
164+
pytest.skip("nftables NAT between namespaces not available") # type: ignore[misc]
151165

152166

153167
class NetworkTestEnv:
@@ -310,7 +324,7 @@ def test_dnsmasq_stopped_on_cleanup(self, net_env: NetworkTestEnv):
310324
@requires_privileges
311325
@requires_nft
312326
@requires_dnsmasq
313-
@requires_nat
327+
@pytest.mark.usefixtures("nat_available")
314328
class TestMasqueradeNat:
315329
"""Test masquerade NAT rules."""
316330

@@ -653,7 +667,7 @@ def test_remove_dns_entry_via_rpc(self, net_env: NetworkTestEnv):
653667
@requires_privileges
654668
@requires_nft
655669
@requires_dnsmasq
656-
@requires_nat
670+
@pytest.mark.usefixtures("nat_available")
657671
class TestOneToOneNatDataPlane:
658672
"""Test actual data-plane connectivity through 1:1 NAT (not just rule presence)."""
659673

@@ -725,7 +739,7 @@ def test_exporter_can_reach_dut_via_public_ip(self, net_env: NetworkTestEnv):
725739
@requires_privileges
726740
@requires_nft
727741
@requires_dnsmasq
728-
@requires_nat
742+
@pytest.mark.usefixtures("nat_available")
729743
class TestDisabledNatIsolation:
730744
"""Test that disabled NAT prevents routing while still allowing local access."""
731745

@@ -772,7 +786,7 @@ def test_dut_can_still_reach_gateway(self, net_env: NetworkTestEnv):
772786
@requires_privileges
773787
@requires_nft
774788
@requires_dnsmasq
775-
@requires_nat
789+
@pytest.mark.usefixtures("nat_available")
776790
class TestTcpConnectivity:
777791
"""Test TCP connectivity through masquerade NAT (not just ICMP ping)."""
778792

0 commit comments

Comments
 (0)