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/AGENTS.md +244 -0
- netboot/__init__.py +394 -0
- netboot/__main__.py +4 -0
- netboot/_netutils.py +341 -0
- netboot/cmds/__init__.py +5 -0
- netboot/cmds/complete.py +24 -0
- netboot/cmds/initiate.py +36 -0
- netboot/content/__init__.py +88 -0
- netboot/dhcp.py +123 -0
- netboot/logging.py +17 -0
- netboot/main.py +232 -0
- netboot/py.typed +0 -0
- netboot/templates/__init__.py +123 -0
- netboot/templates/common.py +21 -0
- netboot/templates/jinja.py +18 -0
- netboot/templates/shell.py +32 -0
- netboot/utils/__init__.py +3 -0
- netboot/utils/config.py +12 -0
- netboot/utils/dicts.py +49 -0
- netboot/utils/misc.py +6 -0
- netboot/utils/net.py +61 -0
- netboot-0.1.0.dist-info/METADATA +154 -0
- netboot-0.1.0.dist-info/RECORD +26 -0
- netboot-0.1.0.dist-info/WHEEL +4 -0
- netboot-0.1.0.dist-info/entry_points.txt +2 -0
- netboot-0.1.0.dist-info/licenses/LICENSE +21 -0
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()]
|
netboot/cmds/__init__.py
ADDED
netboot/cmds/complete.py
ADDED
|
@@ -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
|
netboot/cmds/initiate.py
ADDED
|
@@ -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
|