Skip to content

Commit 31c1b42

Browse files
committed
dhcp client: add a udhcpd fixture and a test with it
1 parent 1e195c4 commit 31c1b42

File tree

8 files changed

+392
-185
lines changed

8 files changed

+392
-185
lines changed

pyroute2/dhcp/client.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,21 @@ def __init__(
5959

6060
# "public api"
6161

62-
async def wait_for_state(self, state: Optional[fsm.State]) -> None:
62+
async def wait_for_state(
63+
self, state: Optional[fsm.State], timeout: Optional[float] = None
64+
) -> None:
6365
'''Waits until the client is in the target state.
6466
6567
Since the state is set to None upon exit,
6668
you can also pass None to wait for the client to stop.
6769
'''
68-
await self._states[state].wait()
70+
try:
71+
await asyncio.wait_for(self._states[state].wait(), timeout=timeout)
72+
except TimeoutError as err:
73+
raise TimeoutError(
74+
f"Timed out waiting for the {state} state. "
75+
f"Current state: {self.state}"
76+
) from err
6977

7078
@fsm.state_guard(fsm.State.INIT, fsm.State.INIT_REBOOT)
7179
async def bootstrap(self):

tests/test_linux/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from uuid import uuid4
33

44
import pytest
5-
from fixtures.dnsmasq import dnsmasq, dnsmasq_options # noqa: F401
5+
from fixtures.dhcp_servers.dnsmasq import dnsmasq, dnsmasq_config # noqa: F401
6+
from fixtures.dhcp_servers.udhcpd import udhcpd, udhcpd_config # noqa: F401
67
from fixtures.interfaces import dhcp_range, veth_pair # noqa: F401
78
from pr2test.context_manager import NDBContextManager, SpecContextManager
89
from utils import require_user
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import abc
2+
from argparse import ArgumentParser
3+
import asyncio
4+
from dataclasses import dataclass
5+
from ipaddress import IPv4Address
6+
from typing import ClassVar, Generic, Literal, Optional, TypeVar
7+
8+
from ..interfaces import DHCPRangeConfig
9+
10+
11+
@dataclass
12+
class DHCPServerConfig:
13+
range: DHCPRangeConfig
14+
interface: str
15+
lease_time: int = 120 # in seconds
16+
max_leases: int = 50
17+
18+
DHCPServerConfigT = TypeVar("DHCPServerConfigT", bound=DHCPServerConfig)
19+
20+
class DHCPServerFixture(abc.ABC, Generic[DHCPServerConfigT]):
21+
22+
BINARY_PATH: ClassVar[Optional[str]] = None
23+
24+
@classmethod
25+
def get_config_class(cls) -> type[DHCPServerConfigT]:
26+
return cls.__orig_bases__[0].__args__[0]
27+
28+
def __init__(self, config: DHCPServerConfigT) -> None:
29+
self.config = config
30+
self.stdout: list[str] = []
31+
self.stderr: list[str] = []
32+
self.process: Optional[asyncio.subprocess.Process] = None
33+
self.output_poller: Optional[asyncio.Task] = None
34+
35+
async def _read_output(self, name: Literal['stdout', 'stderr']):
36+
'''Read stdout or stderr until the process exits.'''
37+
stream = getattr(self.process, name)
38+
output = getattr(self, name)
39+
while line := await stream.readline():
40+
output.append(line.decode().strip())
41+
42+
async def _read_outputs(self):
43+
'''Read stdout & stderr until the process exits.'''
44+
assert self.process
45+
await asyncio.gather(
46+
self._read_output('stderr'), self._read_output('stdout')
47+
)
48+
49+
50+
@abc.abstractmethod
51+
def get_cmdline_options(self) -> tuple[str]:
52+
'''All commandline options passed to the server.'''
53+
54+
55+
async def __aenter__(self):
56+
'''Start the server process and start polling its output.'''
57+
if not self.BINARY_PATH:
58+
raise RuntimeError(f"server binary is missing for {type(self.__name__)}")
59+
self.process = await asyncio.create_subprocess_exec(
60+
self.BINARY_PATH,
61+
*self.get_cmdline_options(),
62+
stdout=asyncio.subprocess.PIPE,
63+
stderr=asyncio.subprocess.PIPE,
64+
env={'LANG': 'C'}, # usually ensures the output is in english
65+
)
66+
self.output_poller = asyncio.Task(self._read_outputs())
67+
return self
68+
69+
async def __aexit__(self, *_):
70+
if self.process:
71+
if self.process.returncode is None:
72+
self.process.terminate()
73+
await self.process.wait()
74+
await self.output_poller
75+
76+
77+
def get_psr() -> ArgumentParser:
78+
psr = ArgumentParser()
79+
psr.add_argument('interface', help='Interface to listen on')
80+
psr.add_argument(
81+
'--router',
82+
type=IPv4Address,
83+
default=None,
84+
help='Router IPv4 address.',
85+
)
86+
psr.add_argument(
87+
'--range-start',
88+
type=IPv4Address,
89+
default=IPv4Address('192.168.186.10'),
90+
help='Start of the DHCP client range.',
91+
)
92+
psr.add_argument(
93+
'--range-end',
94+
type=IPv4Address,
95+
default=IPv4Address('192.168.186.100'),
96+
help='End of the DHCP client range.',
97+
)
98+
psr.add_argument(
99+
'--lease-time',
100+
default=120,
101+
type=int,
102+
help='DHCP lease time in seconds (minimum 2 minutes)',
103+
)
104+
psr.add_argument(
105+
'--netmask',
106+
type=IPv4Address,
107+
default=IPv4Address("255.255.255.0"),
108+
)
109+
return psr
110+
111+
112+
async def run_fixture_as_main(fixture_cls: type[DHCPServerFixture]):
113+
config_cls = fixture_cls.get_config_class()
114+
args = get_psr().parse_args()
115+
range_config = DHCPRangeConfig(
116+
start=args.range_start,
117+
end=args.range_end,
118+
router=args.router,
119+
netmask=args.netmask,
120+
)
121+
conf = config_cls(
122+
range=range_config,
123+
interface=args.interface,
124+
lease_time=args.lease_time,
125+
)
126+
read_lines: int = 0
127+
async with fixture_cls(conf) as dhcp_server:
128+
# quick & dirty stderr polling
129+
while True:
130+
if len(dhcp_server.stderr) > read_lines:
131+
read_lines += len(lines := dhcp_server.stderr[read_lines:])
132+
print(*lines, sep='\n')
133+
else:
134+
await asyncio.sleep(0.2)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import asyncio
2+
from dataclasses import dataclass
3+
from shutil import which
4+
from typing import AsyncGenerator, ClassVar, Optional
5+
6+
import pytest
7+
import pytest_asyncio
8+
from fixtures.interfaces import DHCPRangeConfig
9+
10+
from . import DHCPServerConfig, DHCPServerFixture, run_fixture_as_main
11+
12+
@dataclass
13+
class DnsmasqConfig(DHCPServerConfig):
14+
'''Options for the dnsmasq server.'''
15+
def __iter__(self):
16+
opts = [
17+
f'--interface={self.interface}',
18+
f'--dhcp-range={self.range.start},'
19+
f'{self.range.end},{self.lease_time}',
20+
f'--dhcp-lease-max={self.max_leases}',
21+
]
22+
if router := self.range.router:
23+
opts.append(f"--dhcp-option=option:router,{router}")
24+
return iter(opts)
25+
26+
27+
28+
29+
class DnsmasqFixture(DHCPServerFixture[DnsmasqConfig]):
30+
'''Runs the dnsmasq server as an async context manager.'''
31+
32+
BINARY_PATH: ClassVar[Optional[str]] = which('dnsmasq')
33+
34+
def _get_base_cmdline_options(self) -> tuple[str]:
35+
'''The base commandline options for dnsmasq.'''
36+
return (
37+
'--keep-in-foreground', # self explanatory
38+
'--no-resolv', # don't mess w/ resolv.conf
39+
'--log-facility=-', # log to stdout
40+
'--no-hosts', # don't read /etc/hosts
41+
'--bind-interfaces', # don't bind on wildcard
42+
'--no-ping', # don't ping to check if ips are attributed
43+
)
44+
45+
def get_cmdline_options(self) -> tuple[str]:
46+
'''All commandline options passed to dnsmasq.'''
47+
return (*self._get_base_cmdline_options(), *self.config)
48+
49+
50+
@pytest.fixture
51+
def dnsmasq_config(
52+
veth_pair: tuple[str, str], dhcp_range: DHCPRangeConfig
53+
) -> DnsmasqConfig:
54+
'''dnsmasq options useful for test purposes.'''
55+
return DnsmasqConfig(
56+
range=dhcp_range,
57+
interface=veth_pair[0],
58+
)
59+
60+
61+
@pytest_asyncio.fixture
62+
async def dnsmasq(
63+
dnsmasq_config: DnsmasqConfig,
64+
) -> AsyncGenerator[DnsmasqFixture, None]:
65+
'''A dnsmasq instance running for the duration of the test.'''
66+
async with DnsmasqFixture(config=dnsmasq_config) as dnsf:
67+
yield dnsf
68+
69+
70+
if __name__ == '__main__':
71+
asyncio.run(run_fixture_as_main(DnsmasqFixture))
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
2+
import asyncio
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
from shutil import which
6+
from typing import AsyncGenerator, ClassVar, Optional
7+
8+
import pytest
9+
import pytest_asyncio
10+
11+
from ..interfaces import DHCPRangeConfig
12+
from . import DHCPServerConfig, DHCPServerFixture, run_fixture_as_main
13+
from tempfile import TemporaryDirectory
14+
15+
16+
@dataclass
17+
class UdhcpdConfig(DHCPServerConfig):
18+
arp_ping_timeout_ms: int = 200 # default is 2000
19+
20+
21+
class UdhcpdFixture(DHCPServerFixture[UdhcpdConfig]):
22+
'''Runs the udhcpd server as an async context manager.'''
23+
24+
BINARY_PATH: ClassVar[Optional[str]] = which('busybox')
25+
26+
def __init__(self, config):
27+
super().__init__(config)
28+
self._temp_dir: Optional[TemporaryDirectory[str]] = None
29+
30+
@property
31+
def workdir(self) -> Path:
32+
'''A temporary directory for udhcpd's files.'''
33+
assert self._temp_dir
34+
return Path(self._temp_dir.name)
35+
36+
@property
37+
def config_file(self) -> Path:
38+
'''The udhcpd config file path.'''
39+
return self.workdir.joinpath("udhcpd.conf")
40+
41+
async def __aenter__(self):
42+
self._temp_dir = TemporaryDirectory(
43+
prefix=type(self).__name__
44+
)
45+
self._temp_dir.__enter__()
46+
self.config_file.write_text(self.generate_config())
47+
return await super().__aenter__()
48+
49+
def generate_config(self) -> str:
50+
'''Generate the contents of udhcpd's config file.'''
51+
cfg = self.config
52+
base_workfile = self.workdir.joinpath(self.config.interface)
53+
lease_file = base_workfile.with_suffix(".leases")
54+
pidfile = base_workfile.with_suffix(".pid")
55+
lines = [
56+
("start", cfg.range.start),
57+
("end", cfg.range.end),
58+
("max_leases", cfg.max_leases),
59+
("interface", cfg.interface),
60+
("lease_file", lease_file),
61+
("pidfile", pidfile),
62+
("opt lease", cfg.lease_time),
63+
("opt router", cfg.range.router),
64+
]
65+
return "\n".join(f"{opt}\t{value}" for opt, value in lines)
66+
67+
async def __aexit__(self, *_):
68+
await super().__aexit__(*_)
69+
self._temp_dir.__exit__(*_)
70+
71+
def get_cmdline_options(self) -> tuple[str]:
72+
'''All commandline options passed to udhcpd.'''
73+
return (
74+
'udhcpd',
75+
'-f', # run in foreground
76+
'-a', str(self.config.arp_ping_timeout_ms),
77+
str(self.config_file),
78+
)
79+
80+
81+
@pytest.fixture
82+
def udhcpd_config(
83+
veth_pair: tuple[str, str], dhcp_range: DHCPRangeConfig
84+
) -> UdhcpdConfig:
85+
'''udhcpd options useful for test purposes.'''
86+
return UdhcpdConfig(
87+
range=dhcp_range,
88+
interface=veth_pair[0],
89+
lease_time=1, # very short leases for tests
90+
)
91+
92+
93+
@pytest_asyncio.fixture
94+
async def udhcpd(
95+
udhcpd_config: UdhcpdConfig,
96+
) -> AsyncGenerator[UdhcpdFixture, None]:
97+
'''An udhcpd instance running for the duration of the test.'''
98+
async with UdhcpdFixture(config=udhcpd_config) as dhcp_server:
99+
yield dhcp_server
100+
101+
102+
if __name__ == '__main__':
103+
asyncio.run(run_fixture_as_main(UdhcpdFixture))

0 commit comments

Comments
 (0)