netboot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netboot/_netutils.py ADDED
@@ -0,0 +1,341 @@
1
+ """Vendored network utilities for netboot (internal).
2
+
3
+ A thin, typed convenience layer over the standard library's :mod:`ipaddress`
4
+ plus a handful of host helpers (DNS lookup, ping, local NIC discovery). Used
5
+ internally as ``netboot._netutils`` and re-exported from :mod:`netboot.utils.net`.
6
+
7
+ This is a private, in-tree copy; it may later be extracted back into a standalone
8
+ distribution. Do not depend on the module path from outside netboot.
9
+
10
+ All IP/network types are the concrete :mod:`ipaddress` classes (or thin
11
+ factories over them), so ``.exploded``, ``.network_address``, ``.netmask`` and
12
+ ``addr in network`` membership all behave exactly as the stdlib does.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ipaddress as _ipaddress
18
+ import os as _os
19
+ import platform as _platform
20
+ import re as _re
21
+ import socket as _socket
22
+ from subprocess import run as _run
23
+ from typing import List, Optional, Union
24
+
25
+ # Re-export the concrete stdlib types so consumers can annotate with them.
26
+ from ipaddress import (
27
+ IPv4Address,
28
+ IPv4Interface,
29
+ IPv4Network,
30
+ IPv6Address,
31
+ IPv6Interface,
32
+ IPv6Network,
33
+ )
34
+
35
+ __all__ = [
36
+ "IPAddress",
37
+ "IPInterface",
38
+ "IPNetwork",
39
+ "IPv4Address",
40
+ "IPv4Interface",
41
+ "IPv4Network",
42
+ "IPv6Address",
43
+ "IPv6Interface",
44
+ "IPv6Network",
45
+ "MACAddress",
46
+ "parse_ip",
47
+ "parse_network",
48
+ "is_valid_ip",
49
+ "nslookup",
50
+ "ping",
51
+ "active_nic_addresses",
52
+ "get_ip_address",
53
+ "nic_info",
54
+ "HOST_DN",
55
+ ]
56
+
57
+ __version__ = "0.1.0"
58
+
59
+ #: Fully-qualified (or short) name of the host running this process.
60
+ HOST_DN = _platform.node()
61
+
62
+ # Type aliases describing the values these factories accept / return. Kept as
63
+ # runtime objects (not just annotations) so they read well in tracebacks.
64
+ _AddressValue = Union[str, int, "_ipaddress._BaseAddress"]
65
+ _NetworkValue = Union[str, int, "_ipaddress._BaseNetwork", "_ipaddress._BaseAddress"]
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # IP address / interface / network factories
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def IPAddress(value: _AddressValue) -> Union[IPv4Address, IPv6Address]:
73
+ """Return an :class:`ipaddress.IPv4Address`/:class:`IPv6Address`.
74
+
75
+ Accepts a string (``"10.0.0.5"``), a packed/integer form, or an existing
76
+ address object (returned as-is by the stdlib). This is a factory, not a
77
+ class -- ``isinstance(x, IPAddress)`` is not meaningful; test against
78
+ ``IPv4Address``/``IPv6Address`` instead.
79
+ """
80
+ return _ipaddress.ip_address(value)
81
+
82
+
83
+ def IPInterface(value: _AddressValue) -> Union[IPv4Interface, IPv6Interface]:
84
+ """Return an :class:`ipaddress.IPv4Interface`/:class:`IPv6Interface`.
85
+
86
+ An interface carries both a host address and its network, exposing ``.ip``,
87
+ ``.netmask`` and ``.network`` (each with ``.exploded``), e.g.::
88
+
89
+ IPInterface("10.0.0.5/24").network.network_address.exploded
90
+ """
91
+ return _ipaddress.ip_interface(value)
92
+
93
+
94
+ def IPNetwork(value: _NetworkValue, strict: bool = False) -> Union[IPv4Network, IPv6Network]:
95
+ """Return an :class:`ipaddress.IPv4Network`/:class:`IPv6Network`.
96
+
97
+ Defaults to ``strict=False`` so a host address with a prefix (e.g.
98
+ ``"10.0.0.5/24"``) is accepted and normalised to its network rather than
99
+ raising. Supports ``.network_address``, ``.netmask`` and ``addr in network``
100
+ membership tests.
101
+ """
102
+ return _ipaddress.ip_network(value, strict=strict)
103
+
104
+
105
+ def parse_ip(value: Optional[_AddressValue]) -> Optional[Union[IPv4Address, IPv6Address]]:
106
+ """Coerce ``value`` to an :class:`ipaddress` address, tolerating emptiness.
107
+
108
+ Returns ``None`` for ``None`` or an empty/whitespace-only string -- callers
109
+ frequently hold an as-yet-unresolved ``ip`` field (``""``) and pass it
110
+ straight through, so an empty value maps to a falsy ``None`` rather than
111
+ raising. Any other value is delegated to :func:`IPAddress`, which raises
112
+ :class:`ValueError` on genuinely malformed input.
113
+ """
114
+ if value is None:
115
+ return None
116
+ if isinstance(value, str) and not value.strip():
117
+ return None
118
+ return IPAddress(value)
119
+
120
+
121
+ def parse_network(value: Optional[_NetworkValue]) -> Optional[Union[IPv4Network, IPv6Network]]:
122
+ """Coerce ``value`` to an :class:`ipaddress` network (non-strict).
123
+
124
+ Mirrors :func:`parse_ip`: ``None`` or an empty string yields ``None``;
125
+ anything else is delegated to :func:`IPNetwork`.
126
+ """
127
+ if value is None:
128
+ return None
129
+ if isinstance(value, str) and not value.strip():
130
+ return None
131
+ return IPNetwork(value)
132
+
133
+
134
+ def is_valid_ip(value: object) -> bool:
135
+ """Return ``True`` if ``value`` is a valid IPv4/IPv6 address.
136
+
137
+ Never raises: any input that :func:`ipaddress.ip_address` rejects (including
138
+ non-string types and empty strings) yields ``False``.
139
+ """
140
+ try:
141
+ _ipaddress.ip_address(value) # type: ignore[arg-type]
142
+ return True
143
+ except (ValueError, TypeError):
144
+ return False
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # MAC address
149
+ # ---------------------------------------------------------------------------
150
+
151
+ class MACAddress:
152
+ """An IEEE 802 MAC address.
153
+
154
+ Accepts the common textual forms on construction -- colon (``AA:BB:CC:DD:EE:FF``),
155
+ hyphen (``AA-BB-CC-DD-EE-FF``), dot/Cisco (``aabb.ccdd.eeff``) or bare
156
+ (``AABBCCDDEEFF``) -- as well as an ``int`` or another ``MACAddress``. The
157
+ value is normalised to lowercase and compared/hashed by its canonical bytes,
158
+ so instances are usable as dict keys and set members.
159
+
160
+ ``as_str(sep)`` renders the address with an arbitrary separator between
161
+ octets; ``sep=""`` produces the bare form.
162
+ """
163
+
164
+ #: Compiled pattern matching the accepted textual MAC forms. Exposed as a
165
+ #: class attribute so callers can pre-screen text with
166
+ #: ``MACAddress._VALID_MAC.match(text)`` before attempting construction.
167
+ _VALID_MAC = _re.compile(
168
+ r"^(?:"
169
+ r"[0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}" # colon/hyphen separated
170
+ r"|[0-9A-Fa-f]{4}(?:\.[0-9A-Fa-f]{4}){2}" # dot / Cisco triplets
171
+ r"|[0-9A-Fa-f]{12}" # bare, no separators
172
+ r")$"
173
+ )
174
+
175
+ __slots__ = ("_octets",)
176
+
177
+ def __init__(self, value: Union[str, int, bytes, "MACAddress"]) -> None:
178
+ if isinstance(value, MACAddress):
179
+ self._octets = value._octets
180
+ return
181
+ if isinstance(value, (bytes, bytearray)):
182
+ octets = bytes(value)
183
+ if len(octets) != 6:
184
+ raise ValueError("MAC address must be 6 bytes, got %d" % len(octets))
185
+ self._octets = octets
186
+ return
187
+ if isinstance(value, int):
188
+ if value < 0 or value > 0xFFFFFFFFFFFF:
189
+ raise ValueError("MAC integer out of range: %r" % (value,))
190
+ self._octets = value.to_bytes(6, "big")
191
+ return
192
+ if isinstance(value, str):
193
+ text = value.strip()
194
+ if not self._VALID_MAC.match(text):
195
+ raise ValueError("Invalid MAC address: %r" % (value,))
196
+ hexdigits = _re.sub(r"[.:-]", "", text)
197
+ self._octets = bytes.fromhex(hexdigits)
198
+ return
199
+ raise TypeError("Cannot build MACAddress from %r" % (type(value).__name__,))
200
+
201
+ def as_str(self, sep: str = ":") -> str:
202
+ """Return the MAC as a lowercase string with ``sep`` between octets."""
203
+ return sep.join("%02x" % b for b in self._octets)
204
+
205
+ @property
206
+ def packed(self) -> bytes:
207
+ """The 6 raw bytes of the address."""
208
+ return self._octets
209
+
210
+ def __int__(self) -> int:
211
+ return int.from_bytes(self._octets, "big")
212
+
213
+ def __str__(self) -> str:
214
+ return self.as_str(":")
215
+
216
+ def __repr__(self) -> str:
217
+ return "MACAddress(%r)" % (self.as_str(":"),)
218
+
219
+ def __eq__(self, other: object) -> bool:
220
+ if isinstance(other, MACAddress):
221
+ return self._octets == other._octets
222
+ if isinstance(other, str):
223
+ try:
224
+ return self._octets == MACAddress(other)._octets
225
+ except (ValueError, TypeError):
226
+ return NotImplemented
227
+ return NotImplemented
228
+
229
+ def __hash__(self) -> int:
230
+ return hash(self._octets)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # DNS / reachability
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def nslookup(query: str, ns: Optional[Union[str, List[str]]] = None, type: str = "a") -> List[str]:
238
+ """Resolve ``query`` via DNS and return the answers as a list of strings.
239
+
240
+ Contract: always returns a ``list`` of string records (e.g. one or more
241
+ ``"93.184.216.34"`` for an ``A`` lookup), and an **empty list** when the
242
+ name does not resolve or any DNS error occurs -- never ``None``. Callers can
243
+ therefore write ``if result:`` and index ``result[0]`` safely.
244
+
245
+ :param query: the name (or address, for reverse types) to look up.
246
+ :param ns: optional nameserver, or list of nameservers, to query instead of
247
+ the system resolver.
248
+ :param type: DNS record type (``"a"``, ``"aaaa"``, ``"mx"`` ...).
249
+
250
+ Requires the ``dnspython`` package.
251
+ """
252
+ from dns import resolver as _resolver
253
+
254
+ r = _resolver.Resolver(configure=not ns)
255
+ if isinstance(ns, str):
256
+ ns = [ns]
257
+ if ns:
258
+ r.nameservers = list(ns)
259
+ try:
260
+ answer = r.resolve(query, type)
261
+ except Exception:
262
+ return []
263
+ return [str(record) for record in answer]
264
+
265
+
266
+ def ping(hostname: str, tries: int = 1) -> bool:
267
+ """Return ``True`` if ``hostname`` answers a single ICMP echo within ``tries``.
268
+
269
+ Shells out to the platform ``ping`` (Windows ``-n``/``-w`` vs POSIX
270
+ ``-c``/``-W``), sending one packet per attempt with a 1s timeout, and
271
+ returns on the first success. An empty ``hostname`` is ``False``.
272
+ """
273
+ if not hostname:
274
+ return False
275
+ if _os.name == "nt":
276
+ options = ["-n", "1", "-w", "1000"]
277
+ else:
278
+ options = ["-c", "1", "-W", "1", "-n"]
279
+ tries = tries or 1
280
+ while tries > 0:
281
+ response = _run(["ping", *options, hostname], capture_output=True)
282
+ if response.returncode == 0:
283
+ return True
284
+ tries -= 1
285
+ return False
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Local NIC discovery
290
+ # ---------------------------------------------------------------------------
291
+
292
+ def active_nic_addresses() -> List[IPv4Address]:
293
+ """Return the host's active (non-loopback) IPv4 address as a 1-element list.
294
+
295
+ Resolves the local hostname and filters out ``127.*`` loopback entries.
296
+ Returns an empty list if only loopback addresses are found. Cross-platform.
297
+ """
298
+ try:
299
+ _, _, ips = _socket.gethostbyname_ex(_socket.gethostname())
300
+ except OSError:
301
+ return []
302
+ return [IPv4Address(ip) for ip in ips if not ip.startswith("127.")][:1]
303
+
304
+
305
+ def get_ip_address(nic_name: str) -> str:
306
+ """Return the IPv4 address bound to interface ``nic_name`` (POSIX only).
307
+
308
+ Uses an ``SIOCGIFADDR`` ioctl and therefore requires the POSIX-only
309
+ :mod:`fcntl` module. Raises :class:`OSError` (``NotImplementedError`` under
310
+ Windows, where ``fcntl`` is unavailable).
311
+ """
312
+ import struct
313
+
314
+ try:
315
+ import fcntl
316
+ except ImportError as exc: # pragma: no cover - platform dependent
317
+ raise NotImplementedError("get_ip_address requires POSIX fcntl") from exc
318
+
319
+ s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
320
+ try:
321
+ return _socket.inet_ntoa(
322
+ fcntl.ioctl(
323
+ s.fileno(),
324
+ 0x8915, # SIOCGIFADDR
325
+ struct.pack("256s", nic_name[:15].encode("utf-8")),
326
+ )[20:24]
327
+ )
328
+ finally:
329
+ s.close()
330
+
331
+
332
+ def nic_info() -> List[tuple]:
333
+ """Return ``[(name, ipv4), ...]`` for each interface (POSIX only).
334
+
335
+ Enumerates interfaces via :func:`socket.if_nameindex` (POSIX only) and pairs
336
+ each with its :func:`get_ip_address`. Raises on platforms without these
337
+ facilities (e.g. Windows).
338
+ """
339
+ if not hasattr(_socket, "if_nameindex"): # pragma: no cover - platform dependent
340
+ raise NotImplementedError("nic_info requires POSIX socket.if_nameindex")
341
+ return [(name, get_ip_address(name)) for _, name in _socket.if_nameindex()]
@@ -0,0 +1,5 @@
1
+ """Built-in netboot commands.
2
+
3
+ Each module here is a duho command exposing ``register(parser, args)`` and
4
+ ``run(netboot, args, conf)``; :mod:`netboot.main` discovers them as ``netboot.cmds``.
5
+ """
@@ -0,0 +1,24 @@
1
+ """Complete the PXE process for a target: run post-boot cleanup tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from netboot import Pixie
8
+ from netboot.logging import LOGGER
9
+
10
+
11
+ def register(parser: argparse.ArgumentParser, args) -> None:
12
+ parser.add_argument(
13
+ "target", help="-/Hostname/MAC/IP of the target to complete"
14
+ )
15
+
16
+
17
+ def run(netboot: Pixie, args, conf: dict) -> int:
18
+ target = netboot.lookup_target(args.target)
19
+ if target is None:
20
+ LOGGER.error("Target not found: %s", args.target)
21
+ return 1
22
+ LOGGER.info("Running post-PXE tasks for: %s", args.target)
23
+ netboot.complete(target)
24
+ return 0
@@ -0,0 +1,36 @@
1
+ """Initiate the PXE process for a target: render artifacts and arm DHCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from netboot import Pixie
8
+ from netboot.logging import LOGGER
9
+
10
+
11
+ def register(parser: argparse.ArgumentParser, args) -> None:
12
+ parser.add_argument(
13
+ "target", help="-/Hostname/MAC/IP of the target to initiate"
14
+ )
15
+ parser.add_argument(
16
+ "--iscsi",
17
+ action="store_true",
18
+ default=False,
19
+ help="Prepare as an iSCSI LUN",
20
+ )
21
+
22
+
23
+ def run(netboot: Pixie, args, conf: dict) -> int:
24
+ target = netboot.lookup_target(args.target)
25
+ if target is None:
26
+ LOGGER.error("Target not found: %s", args.target)
27
+ return 1
28
+ LOGGER.info(
29
+ "Initiating Hostname: %s IP: %s MAC: %s",
30
+ target.hostname,
31
+ target.ip,
32
+ target.mac,
33
+ )
34
+ netboot.initialize(target)
35
+ LOGGER.info("Netboot initiated for: %s", args.target)
36
+ return 0
@@ -0,0 +1,88 @@
1
+ import typing as _ty
2
+ from typing import Union
3
+
4
+ from pathlib_next import Pathname
5
+ from pathlib_next import PosixPathname as RepoPath
6
+ from pathlib_next.uri import Source, UriPath
7
+
8
+ from ..utils.config import Namespace as _NS
9
+ from ..utils.net import Host
10
+
11
+
12
+ class Resource(_NS):
13
+ path: Pathname
14
+ src: str
15
+
16
+ def __truediv__(self, key):
17
+ return Resource(path=self.path / key, src=self.src)
18
+
19
+ @classmethod
20
+ def _parse_path(cls, path: Union[str, Pathname]):
21
+ if not isinstance(path, Pathname):
22
+ path = RepoPath(path)
23
+ return path
24
+
25
+
26
+ class Repository(_NS):
27
+ address: Host
28
+ services: dict[str, UriPath]
29
+ local: "_ty.Optional[UriPath]"
30
+
31
+ def __init__(self, **kwargs) -> None:
32
+ self.services = {}
33
+ kwargs.setdefault("address", None)
34
+ kwargs.setdefault("local", None)
35
+ super().__init__(**kwargs)
36
+ if not isinstance(self.address, Host):
37
+ self.address = Host(self.address)
38
+ if self.local and not isinstance(self.local, UriPath):
39
+ self.local = UriPath(self.local)
40
+
41
+ def __getitem__(self, key: tuple[str, str]) -> UriPath:
42
+ rel_path, service = key
43
+ return self.get(rel_path, service=service)
44
+
45
+ def __truediv__(self, key) -> "Repository":
46
+ return self.joinpath(key)
47
+
48
+ def joinpath(self, subpath: str = None):
49
+ repo = Repository(address=self.address, services={}, local=self.local)
50
+ for srvc in self.services:
51
+ repo.services[srvc] = (
52
+ f"{self.services[srvc]}/{subpath}" if subpath else self.services[srvc]
53
+ )
54
+ if self.local:
55
+ repo.local = self.local / subpath if subpath else self.local
56
+ else:
57
+ # Keep .local a Pathname (not a raw str) so chained joinpath works.
58
+ repo.local = RepoPath(subpath) if subpath else None
59
+ return repo
60
+
61
+ def get(self, *path: Union[RepoPath, str], service: str = None) -> UriPath:
62
+ path: RepoPath = RepoPath(
63
+ *[(p if isinstance(p, Pathname) else RepoPath(p)).as_posix() for p in path]
64
+ )
65
+ rel_path = path.as_posix().lstrip("/")
66
+ baseuri = self.service(service)
67
+ if not baseuri:
68
+ return None
69
+ return baseuri / rel_path
70
+
71
+ def service(self, name: str):
72
+ if name is None:
73
+ baseuri = self.local
74
+ name = "file"
75
+ host = ""
76
+ else:
77
+ baseuri = self.services.get(name, None)
78
+ host = str(self.address.try_ip())
79
+ if baseuri is None:
80
+ return None
81
+ if not isinstance(baseuri, UriPath):
82
+ baseuri = UriPath(baseuri)
83
+ if not baseuri.source:
84
+ baseuri = baseuri.with_source(
85
+ Source(scheme=name, userinfo=None, host=host, port=None)
86
+ )
87
+
88
+ return baseuri
netboot/dhcp.py ADDED
@@ -0,0 +1,123 @@
1
+ import typing as _ty
2
+ from argparse import Namespace
3
+
4
+ from . import _netutils as netutils
5
+ from yaconfiglib import OpaqueMerge
6
+
7
+ from .utils.net import IPAddress, IPInterface, IPNetwork
8
+
9
+ if _ty.TYPE_CHECKING:
10
+ from . import PixieContext
11
+
12
+ from urllib.parse import urlparse
13
+
14
+
15
+ def _iter_subclasses(cls: type) -> "_ty.Iterator[type]":
16
+ """Yield every subclass of ``cls``, recursively (not just direct children).
17
+
18
+ Plugin ``DhcpServer`` handlers loaded via ``--load-module`` may subclass an
19
+ intermediate base, so a one-level ``__subclasses__()`` scan would miss them.
20
+ """
21
+ for sub in cls.__subclasses__():
22
+ yield sub
23
+ yield from _iter_subclasses(sub)
24
+
25
+
26
+ class DhcpServer:
27
+ """Base for DHCP backends. ``DhcpServer(uri)`` dispatches on the URI scheme.
28
+
29
+ A concrete backend is a subclass whose lowercased class name matches the URI
30
+ scheme (e.g. ``class dnsmasq(DhcpServer)`` handles ``dnsmasq://...``); such
31
+ backends are typically provided by a plugin module imported via
32
+ ``--load-module``. Subclassing at any depth is honoured.
33
+ """
34
+
35
+ DEFAULT_SCHEME = None
36
+
37
+ def __new__(cls, uri: str):
38
+ if cls is DhcpServer:
39
+ parsed = urlparse(uri)
40
+ scheme = parsed.scheme or cls.DEFAULT_SCHEME
41
+ for sub in _iter_subclasses(cls):
42
+ if sub.__name__.lower() == scheme:
43
+ return object.__new__(sub)
44
+ raise ValueError(
45
+ f"No DhcpServer backend registered for scheme {scheme!r} "
46
+ f"(uri={uri!r}); import a plugin module providing it via --load-module"
47
+ )
48
+ return object.__new__(cls)
49
+
50
+ def __init__(self, uri: str):
51
+ self.uri = uri
52
+
53
+ def remove_target(self, netboot: "PixieContext"):
54
+ pass
55
+
56
+ def add_target(self, netboot: "PixieContext"):
57
+ pass
58
+
59
+
60
+ class DhcpZone(Namespace, OpaqueMerge):
61
+ network: IPNetwork
62
+ gateway: "_ty.Optional[IPAddress]"
63
+ domain: "_ty.Optional[str]"
64
+ search: "list[str]"
65
+ nameservers: "list[IPAddress]"
66
+ globals: dict
67
+ dhcpservers: "list[DhcpServer]"
68
+
69
+ @property
70
+ def nameserver(self):
71
+ return self.nameservers[0] if self.nameservers else ""
72
+
73
+ def get_local_server(self, servers: "list[IPAddress]", default: IPAddress):
74
+ for server in servers:
75
+ if server in self.network:
76
+ return server
77
+ return default
78
+
79
+ def __init__(self, **kwargs) -> None:
80
+ _gateway = kwargs.get("gateway")
81
+ _network = kwargs.get("network")
82
+ _netmask = kwargs.get("netmask")
83
+ if _gateway and not _network:
84
+ gw: netutils.IPv4Interface = IPInterface(_gateway)
85
+ if not _netmask and isinstance(_gateway, str) and "/" in _gateway:
86
+ _netmask = gw.netmask.exploded
87
+ kwargs["gateway"] = gw.ip.exploded
88
+ if _netmask:
89
+ _network = gw.network.network_address.exploded
90
+
91
+ if isinstance(_network, str) and "/" in _network:
92
+ net = IPNetwork(_network)
93
+ _network = net.network_address.exploded
94
+ _netmask = net.netmask.exploded
95
+ if _network:
96
+ if _netmask and isinstance(_network, str):
97
+ _network = f"{_network}/{_netmask}"
98
+ kwargs["network"] = _network
99
+
100
+ kwargs.pop("netmask", None)
101
+ kwargs.setdefault("dhcpservers", [])
102
+ super().__init__(**kwargs)
103
+ self.dhcpservers = [
104
+ server if isinstance(server, DhcpServer) else DhcpServer(server)
105
+ for server in (self.dhcpservers or [])
106
+ ]
107
+ self.network = netutils.parse_network(self.network)
108
+ for prop, ctr in [("nameservers", netutils.parse_ip), ("search", str)]:
109
+ value = getattr(self, prop, None)
110
+ if not value:
111
+ setattr(self, prop, [])
112
+ elif not isinstance(value, list):
113
+ setattr(self, prop, [value])
114
+ vals = getattr(self, prop)
115
+ for i, val in enumerate(vals):
116
+ vals[i] = ctr(val)
117
+
118
+ for prop in ["gateway", "domain"]:
119
+ if not getattr(self, prop, None):
120
+ setattr(self, prop, None)
121
+
122
+ if self.gateway:
123
+ self.gateway = netutils.parse_ip(self.gateway)
netboot/logging.py ADDED
@@ -0,0 +1,17 @@
1
+ """The netboot logger, plus best-effort quieting of chatty optional deps."""
2
+
3
+ import logging
4
+
5
+ LOGGER = logging.getLogger("NETBOOT")
6
+
7
+ # Quiet third-party loggers if those libraries end up in the process (e.g. a repo
8
+ # backend pulls in urllib3/paramiko). Setting a level does not import anything.
9
+ logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
10
+ logging.getLogger("paramiko.transport").setLevel(logging.WARNING)
11
+
12
+ try: # pragma: no cover - only when urllib3 is actually installed
13
+ import urllib3
14
+
15
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
16
+ except Exception:
17
+ pass