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 ADDED
@@ -0,0 +1,244 @@
1
+ # `netboot` — public API header
2
+
3
+ Header-file-style reference for the `netboot` package: every public export with
4
+ its signature, arguments, contract, and gotchas, so this module can be
5
+ consumed without reading its source. Kept current with the public API. For the
6
+ project overview, install instructions and CLI usage, see the repo-root
7
+ overview doc.
8
+
9
+ ## Engine (`netboot` / `netboot.__init__`)
10
+
11
+ - **`Pixie(hooks=(), **config)`** — the engine. `config` is the merged config
12
+ mapping: `targets`, `images`, `dhcpzones`, `repos` (each a `dict[id, ...]`
13
+ built into the corresponding class via its type hints — `TypeError` from the
14
+ value class falls back to a no-`_id` constructor call), `globals` (dict,
15
+ deep-copied), `defaults` (per-collection default mappings), plus any other
16
+ annotated `Pixie` attribute. `hooks` is a sequence of callables or
17
+ `"module.func"` import-path strings; resolved once in `__new__`. Every
18
+ config-driven step fires a `PixieEvent` through the hook chain (see below)
19
+ so hooks can intercept object construction and property values before
20
+ they're set.
21
+ - **`.targets`** / **`.dhcpzones`** / **`.images`** / **`.repos`** —
22
+ `dict[str, ...]` of `PixieTarget` / `DhcpZone` / `PixieImage` /
23
+ `Repository`, keyed by config id.
24
+ - **`.globals`** — `dict`, layered into every render context.
25
+ - **`.hook(event, value=None, **kwargs) -> value`** — run the hook chain for
26
+ `event`, threading `value` through each `f(event, netboot, value, kwargs)`
27
+ and returning the (possibly transformed) result.
28
+ - **`.lookup_target(target: str) -> PixieTarget | None`** — exact id match
29
+ first, else the first target whose hostname starts with `target`
30
+ (case-insensitive), or whose MAC or IP equals it. Fires
31
+ `PixieEvent.LookupTarget` (may substitute a `PixieTarget` directly) then
32
+ `PixieEvent.FoundTarget`; `None` if nothing resolves to a `PixieTarget`.
33
+ - **`.lookup_image(name: str, target=None) -> PixieImage`** — the image
34
+ whose `.match(img_name, name)` returns the highest truthy value (default
35
+ `PixieImage.match` is exact-name equality); falls back to an empty dict
36
+ if nothing matches (**not** `None` — check truthiness carefully). Fires
37
+ `PixieEvent.FoundTargetImage`.
38
+ - **`.lookup_dhcpzone(name: str, target=None) -> DhcpZone | None`** — by id;
39
+ if `name` is empty and `target` is given, uses `target.dhcpzone` or finds
40
+ the zone whose `.network` contains `target.ip` (and caches the id back
41
+ onto `target.dhcpzone`). Fires `PixieEvent.FoundTargetDhcpzone`.
42
+ - **`.make_context(target, globals: list[dict] = None) -> PixieContext`** —
43
+ resolves image + dhcp zone for `target`, merges
44
+ `[self.globals, *globals, image.globals, dhcpzone.globals, target.globals]`
45
+ (image/dhcpzone/target objects are shared across targets and never
46
+ mutated) into a fresh `PixieContext`, attaches a new `Renderer`/`Loader`
47
+ over `config["templates"]`, and sets `.version`. Raises a plain
48
+ `Exception` if the target's image or dhcp zone can't be resolved. Fires
49
+ `PixieEvent.PixieContextForTarget`.
50
+ - **`.initialize(target) -> PixieContext`** — `make_context` then
51
+ `ctx.pxe_init(self)` (arms every `dhcpzone.dhcpservers` for the target).
52
+ Fires `StartPixieInitialize` / `EndPixieInitialize`.
53
+ - **`.complete(target) -> PixieContext`** — `make_context` then
54
+ `ctx.pxe_complete(self)` (disarms every `dhcpzone.dhcpservers`). Fires
55
+ `StartPixieComplete` / `EndPixieComplete`.
56
+ - **`.VERSION`** — class attr, `netboot.__version__` at class-definition time.
57
+
58
+ - **`PixieEvent`** (`StrEnum`) — hook event names: `NewPixieObject`,
59
+ `StartPixieInit`, `SetPixieProperty`, `PixieInitiated`, `LookupTarget`,
60
+ `FoundTarget`, `FoundTargetImage`, `FoundTargetDhcpzone`,
61
+ `PixieContextForTarget`, `StartPixieInitialize`, `EndPixieInitialize`,
62
+ `StartPixieComplete`, `EndPixieComplete`.
63
+
64
+ - **`PixieTarget(**kwargs)`** (`argparse.Namespace` + `yaconfiglib.OpaqueMerge`)
65
+ — `_id`, `hostname`, `ip` (`IPAddress`), `mac` (`MACAddress`), `image`,
66
+ `dhcpzone`, `globals` (`dict`), `template_path` (`list[str | Path]`).
67
+ Construction fills gaps: a MAC-shaped `_id` with no explicit `mac` is
68
+ adopted as the MAC (else `mac` defaults to the null MAC
69
+ `00:00:00:00:00:00`); if `ip`/`hostname` are missing, resolves `hostname`
70
+ via reverse/forward DNS lookup (`netboot._netutils.nslookup`) or infers
71
+ `hostname`/`ip` from `_id` when it looks like one; `hostname` is
72
+ lower-cased. Requires the `dns` extra for hostname resolution to actually
73
+ find an IP (silently yields empty otherwise).
74
+
75
+ - **`PixieImage(**kwargs)`** (`content.Resource`) — `template_path`,
76
+ `globals`. **`.match(name: str, check: str)`** — override point for custom
77
+ image-selection logic; default is `name == check`. Returning a comparable
78
+ (e.g. `int`) instead of a bare bool lets `Pixie.lookup_image` prefer the
79
+ best of several matches.
80
+
81
+ - **`PixieContext(**kwargs)`** (`argparse.Namespace`) — built by
82
+ `Pixie.make_context`, not constructed directly. Fields: `target`, `image`,
83
+ `dhcpzone`, `repos` (`dict[str, Repository]`), `resources`
84
+ (`dict[str, Resource]`), `generated` (`datetime`, set at construction),
85
+ `version` (`str`), `templates`, `_renderer` (a `templates.Renderer`).
86
+ - **`.render(filename: str, strict=True) -> str | None`** — render a
87
+ template found by name/suffix search (see `templates.Loader` below)
88
+ against this context. `strict=True` (default) re-raises render errors;
89
+ `strict=False` logs at DEBUG and returns `None`.
90
+ - **`.resource(name: str | Resource, service: str = None) -> UriPath | None`**
91
+ — resolve a resource (by id, looked up in `.resources`, or a `Resource`
92
+ directly) to a fetchable URI via its repo's `service`. `None` if the repo
93
+ or resource can't be found.
94
+ - **`.resource_repo(name: str) -> Repository | None`** — the `Repository`
95
+ backing resource `name`.
96
+ - **`.searchpaths -> list[Path]`** — `target.template_path + image.template_path`,
97
+ consulted (before the engine-wide `config["templates"]`) when resolving a
98
+ template name.
99
+ - **`.pxe_init(netboot) -> Self`** / **`.pxe_complete(netboot) -> Self`** —
100
+ arm/disarm every `dhcpzone.dhcpservers` for this context; called by
101
+ `Pixie.initialize`/`.complete`, not usually invoked directly.
102
+
103
+ ## DHCP (`netboot.dhcp`)
104
+
105
+ - **`DhcpServer(uri: str)`** — base class for DHCP backends, dispatched by
106
+ URI scheme: `DhcpServer("dnsmasq://...")` returns an instance of whichever
107
+ registered subclass has a matching lowercased class name (any subclass
108
+ depth, so a plugin may subclass an intermediate base). Raises `ValueError`
109
+ if no subclass matches the scheme — import the plugin module first (e.g.
110
+ via CLI `--load-module`). `.uri` holds the original URI.
111
+ - **`.add_target(ctx: PixieContext)`** / **`.remove_target(ctx)`** — no-ops
112
+ on the base class; a backend overrides these to actually arm/disarm.
113
+ - **`DhcpZone(**kwargs)`** (`Namespace` + `OpaqueMerge`) — `network`
114
+ (`IPNetwork`), `gateway` (`IPAddress | None`), `domain` (`str | None`),
115
+ `search` (`list[str]`), `nameservers` (`list[IPAddress]`), `globals`,
116
+ `dhcpservers` (`list[DhcpServer]`, strings coerced via `DhcpServer(uri)`).
117
+ Accepts `gateway`+`netmask`, or a CIDR `network`/`gateway` string, and
118
+ derives the rest. **`.nameserver`** — first of `.nameservers`, or `""`.
119
+ **`.get_local_server(servers, default)`** — first `server` contained in
120
+ `.network`, else `default`.
121
+
122
+ ## Content (`netboot.content`)
123
+
124
+ - **`Resource(**kwargs)`** — `path` (`Pathname`, coerced via `_parse_path`),
125
+ `src` (repo id string). `resource / "sub"` joins the path, same `src`.
126
+ - **`Repository(**kwargs)`** — `address` (`Host`), `services`
127
+ (`dict[str, UriPath]`), `local` (`UriPath | None`, a filesystem/local
128
+ service). `repo / "sub"` (`.joinpath`) returns a **new** `Repository` with
129
+ every service (and `.local`) suffixed by `sub` — the original is untouched.
130
+ **`.get(*path, service=None) -> UriPath | None`** — join `path` onto the
131
+ named service's base URI (`service=None` → `.local`, scheme `"file"`);
132
+ `None` if that service isn't defined. **`.service(name) -> UriPath | None`**
133
+ — the base URI for `name` (host filled in from `.address.try_ip()` for
134
+ non-local services). `repo[path, service]` is sugar for `.get(path, service=service)`.
135
+
136
+ ## Templates (`netboot.templates`)
137
+
138
+ - **`Loader(searchpaths, template_types=(JinjaTemplate, ShellTemplate))`** — a
139
+ Jinja2 `BaseLoader`. Resolves a template name against
140
+ `ctx.searchpaths + searchpaths` (context-specific paths first), trying each
141
+ name `ctx._template_names(...)` yields (MAC, hostname, IP, then the bare
142
+ suffix — first existing file wins) before falling back to the next search
143
+ path. A name may carry `k=v;flag:` options before the final `:` (parsed but
144
+ not currently consulted by name selection). Picks the first
145
+ `template_types` entry whose `.can_process(path, source)` is true; raises
146
+ `jinja2.TemplateNotFound` if nothing matches, or a plain `Exception` if a
147
+ matching type has no usable engine.
148
+ - **`Renderer`** — alias for `jinja2.Environment`; one is created per
149
+ `PixieContext` (never share one across contexts — `globals["ctx"]` is
150
+ mutated per render).
151
+ - **`Template`** — minimal base (`.render(**globals)`, classmethod
152
+ `.can_process(file, template) -> bool`, both no-ops/`False` on the base).
153
+ - **`JinjaTemplate`** (`.j2`/`.jinja`/`.jinja2` suffix) — a real
154
+ `jinja2.Template`; `.render()` additionally injects `shell_quote`, `Path`
155
+ (`pathlib.Path`) and `Uri` (`pathlib_next.uri.UriPath`) into the render
156
+ globals.
157
+ - **`ShellTemplate`** (matches any suffix — keep it **last** in
158
+ `template_types`) — `string.Template` with `%`-delimited placeholders;
159
+ `.render()` flattens the context (`utils.flatten`, keys joined with `_`,
160
+ list items by index) into `UPPERCASE` substitution variables (`None` → `""`,
161
+ `bool` → `"true"`/`"false"`).
162
+
163
+ ## Utils (`netboot.utils`)
164
+
165
+ - **`Namespace`** (`netboot.utils.config`) — `yaconfiglib.TypedNamespace` +
166
+ `OpaqueMerge`; the shared base for netboot's config objects (applies
167
+ `_parse_<prop>` coercers at construction, and marks the built object as
168
+ merge-opaque — a later config layer replaces it wholesale rather than
169
+ merging field-by-field).
170
+ - **`Host(address: str | Host | None = None)`** — a hostname-or-IP repo
171
+ address. **`.try_ip() -> IPAddress | str`** — resolves to an `IPAddress`
172
+ (IP literal as-is, hostname via DNS — needs the `dns` extra); falls back to
173
+ the original string on resolution failure. Equality/hash by `.address`.
174
+ - **`flatten(map, _prefix="") -> dict`** — recursively flattens a
175
+ dict/`Namespace`/list into a single-level dict, joining keys with `_`
176
+ (`{"a": {"b": 1}}` → `{"a_b": 1}`) and using list indices as keys.
177
+ - **`shell_quote(text: str | list[str], quote='"') -> list[str]`** — wrap each
178
+ string in `quote`; always returns a list, even for a single string input.
179
+ - **`arr_get(arr, pos, default=None)`** — `arr[pos]` or `default` if out of
180
+ range.
181
+ - **`import_(name: str) -> object`** — import `"pkg.mod.attr"` and return
182
+ `attr` (splits on the last dot).
183
+ - **IP/MAC re-exports** (from the vendored `netboot._netutils`, also available
184
+ as `netboot.utils.net.*`): `IPAddress`, `IPInterface`, `IPNetwork` (factories
185
+ over stdlib `ipaddress`; `IPNetwork` defaults `strict=False`), `parse_ip`/
186
+ `parse_network` (tolerate `None`/empty → `None`), `is_valid_ip` (never
187
+ raises), `MACAddress` (accepts colon/hyphen/Cisco-dot/bare textual forms,
188
+ int, or bytes; `.as_str(sep)`, `.packed`, hashable/comparable),
189
+ `active_nic_addresses`, `ping`, `nslookup` (needs the `dns` extra; always
190
+ returns a `list`, empty on any failure, never `None`). `netboot._netutils` is
191
+ a private, in-tree module — import these names via `netboot.utils` /
192
+ `netboot.utils.net`, not the private path directly.
193
+
194
+ ## Logging (`netboot.logging`)
195
+
196
+ - **`LOGGER`** — the `"NETBOOT"` logger. Importing this module also quiets
197
+ `urllib3.connectionpool` / `paramiko.transport` to `WARNING` and disables
198
+ urllib3's insecure-request warning, best-effort, without importing those
199
+ libraries itself.
200
+
201
+ ## CLI driver (`netboot.main`)
202
+
203
+ Thin driver over `duho.app`; `duho` owns command discovery, parser build,
204
+ config/env layering and parsing. Netboot overrides only *dispatch*: it loads
205
+ the layered YAML config into one `Pixie` object, then runs the selected
206
+ command against it.
207
+
208
+ - **`main(name=None, argv=None) -> int`** — build the app, parse `argv`,
209
+ dispatch. `name` defaults to `"pixie"` — the CLI's identity: it is the prog
210
+ name **and** the `duho.env.Env` prefix, so `PIXIE_<KEY>` env vars (and an
211
+ optional `pixie_env` companion module of defaults, autoloaded from
212
+ `sys.path`/CWD) supply app settings; the resolved `Env` is attached to the
213
+ dispatched instance as `_env_`. The `netboot` name is only the
214
+ library/import package. This is the console-script
215
+ (`pixie = netboot.main:main`) and `python -m netboot` entry point.
216
+ - **`PixieArgs`** (`duho.LoggingArgs` mixin) — the global CLI fields:
217
+ `config` (`-c/--config`), `baseconfig` (default `./config`), `load_module`
218
+ (`-l/--load-module`, repeatable, colon-extendable), `cmdspath`
219
+ (`--cmdspath`, repeatable, `os.pathsep`-extendable).
220
+ - **`Pixie_`** (`PixieArgs` + `duho.Cli`) — the runnable app root;
221
+ `_version_` is `netboot.__version__`.
222
+ - **`parse_path(path: str | Path) -> Path`** — a bare path → `LocalPath`; a
223
+ path containing `:` (a URI scheme) → `UriPath`.
224
+ - Command-module contract (built-ins in `netboot.cmds`; discovered the same
225
+ way via `--cmdspath` / `PIXIE_CMDS_PATH`, `os.pathsep`-separated): a module
226
+ exposing `register(parser, args)` (add its argparse arguments) and
227
+ `run(netboot: Pixie, args, conf: dict) -> int | None` (`conf` is the raw
228
+ merged config dict, deep-copied before `Pixie` construction). A later
229
+ command source wins on a name clash. A module whose `run` does **not**
230
+ accept at least 3 positional params (no netboot-first signature) is instead
231
+ dispatched through plain `duho.run_command(command, instance)`.
232
+ - Loading the config (`-c/--config`, else `<baseconfig>/pixie.yaml`) requires
233
+ the `config` extra (`pyyaml`); raises `ImportError` with an install hint
234
+ otherwise. `conf["templates"]` always gets the CWD's `templates` dir
235
+ prepended.
236
+
237
+ ## Built-in commands (`netboot.cmds`)
238
+
239
+ - **`initiate <target> [--iscsi]`** — `netboot.lookup_target` then
240
+ `netboot.initialize(target)`. `--iscsi` is accepted but not yet consumed by
241
+ the built-in logic (a hook/plugin extension point). Exit 1 if the target
242
+ isn't found.
243
+ - **`complete <target>`** — `netboot.lookup_target` then
244
+ `netboot.complete(target)`. Exit 1 if the target isn't found.
netboot/__init__.py ADDED
@@ -0,0 +1,394 @@
1
+ import datetime
2
+ import enum as _enum
3
+ import typing as _ty
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+
7
+ try:
8
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
9
+
10
+ try:
11
+ __version__ = _pkg_version("netboot")
12
+ except PackageNotFoundError: # running from a source tree without install
13
+ __version__ = "0.0.0"
14
+ except ImportError: # pragma: no cover - importlib.metadata is stdlib on 3.9+
15
+ __version__ = "0.0.0"
16
+
17
+ # Compat: StrEnum introduced in Python 3.11; emulate for older Pythons
18
+ try:
19
+ from enum import StrEnum
20
+ except ImportError:
21
+ class StrEnum(str, _enum.Enum):
22
+ def __str__(self):
23
+ return self.value
24
+
25
+ from argparse import Namespace
26
+ from typing import Mapping, get_type_hints, Union
27
+
28
+ from . import _netutils as netutils
29
+ from pathlib_next.uri.schemes import * # noqa: F401,F403
30
+ from yaconfiglib import OpaqueMerge
31
+ from yaconfiglib import typed_merge as mergeObjects
32
+
33
+ from .content import Repository, Resource
34
+ from .dhcp import DhcpZone
35
+ from .logging import LOGGER
36
+ from .templates import Loader, Renderer
37
+ from .utils import IPAddress, MACAddress, T
38
+ from .utils.misc import import_
39
+
40
+
41
+ class PixieTarget(Namespace, OpaqueMerge):
42
+ _id: str
43
+ hostname: str
44
+ ip: IPAddress
45
+ mac: MACAddress
46
+ image: str
47
+ dhcpzone: str
48
+ globals: dict
49
+ template_path: list[Union[str, Path]]
50
+
51
+ #: MAC value treated as "unset" (a target keyed by hostname/IP has no MAC).
52
+ _NULL_MAC = "00:00:00:00:00:00"
53
+
54
+ def __init__(self, **kwargs) -> None:
55
+ for prop, key in {
56
+ "dhcpzone": "",
57
+ "mac": "",
58
+ "ip": "",
59
+ "image": "",
60
+ "hostname": "",
61
+ "template_path": [],
62
+ }.items():
63
+ kwargs.setdefault(prop, key)
64
+ super().__init__(**kwargs)
65
+ # If no MAC was given but the id itself is a MAC, adopt it; otherwise
66
+ # fall back to the null MAC so downstream `.mac` is always a MACAddress.
67
+ if not self.mac or str(self.mac) == self._NULL_MAC:
68
+ if MACAddress._VALID_MAC.match(str(self._id)):
69
+ self.mac = self._id
70
+ else:
71
+ self.mac = self._NULL_MAC
72
+ if not isinstance(self.mac, MACAddress):
73
+ self.mac = MACAddress(self.mac)
74
+ resolve = not self.ip or not self.hostname
75
+ while resolve:
76
+ resolve = False
77
+ not_mac = not MACAddress._VALID_MAC.match(self._id)
78
+ id_is_ip = netutils.is_valid_ip(self._id)
79
+ if not self.ip and self.hostname:
80
+ _resolved = netutils.nslookup(self.hostname)
81
+ if _resolved:
82
+ self.ip = _resolved[0]
83
+ resolve = True
84
+ if not self.ip and id_is_ip:
85
+ self.ip = self._id
86
+ resolve = True
87
+ if not self.hostname and not_mac and not id_is_ip:
88
+ self.hostname = self._id
89
+ resolve = True
90
+ if self.ip:
91
+ self.ip = netutils.parse_ip(self.ip)
92
+ self.hostname = self.hostname.lower()
93
+
94
+
95
+ class PixieImage(Resource):
96
+ template_path: list["Path"]
97
+ globals: dict
98
+
99
+ def match(self, name: str, check: str):
100
+ return name == check
101
+
102
+
103
+ class PixieContext(Namespace):
104
+ target: PixieTarget
105
+ image: PixieImage
106
+ dhcpzone: DhcpZone
107
+ repos: dict[str, Repository]
108
+ generated: datetime.datetime
109
+ resources: dict[str, Resource]
110
+ version: str
111
+ templates: list[Union[str, Path]]
112
+ _renderer: Renderer
113
+
114
+ def __init__(self, **kwargs) -> None:
115
+ self.dhcp_server = None
116
+ self.generated = datetime.datetime.now()
117
+ super().__init__(**kwargs)
118
+
119
+ def init(self, netboot: "Pixie"): ...
120
+
121
+ def resource(self, name: Union[str, Resource], service: str = None):
122
+ if isinstance(name, Resource):
123
+ repo = self.repos.get(name.src, None)
124
+ path = name.path
125
+ else:
126
+ path = None
127
+ repo = self.resource_repo(name)
128
+ if not repo:
129
+ return
130
+ if not path:
131
+ path = self.resources[name].path
132
+ return repo.get(path, service=service)
133
+
134
+ def resource_repo(self, name: str):
135
+ resource = self.resources.get(name)
136
+ if not resource:
137
+ return
138
+ return self.repos.get(resource.src)
139
+
140
+ def pxe_init(self, config: "Pixie"):
141
+ for dhcpserver in self.dhcpzone.dhcpservers:
142
+ dhcpserver.add_target(self)
143
+ return self
144
+
145
+ def pxe_complete(self, config: "Pixie"):
146
+ for dhcpserver in self.dhcpzone.dhcpservers:
147
+ dhcpserver.remove_target(self)
148
+ return self
149
+
150
+ def _template_names(self, suffix: Union[list[str], str], **options) -> list[str]:
151
+ # ``options`` (a ``k=v:name`` template spec) is accepted for forward
152
+ # compatibility; name selection does not use it today.
153
+ suffixes = suffix if isinstance(suffix, list) else [suffix]
154
+ ip = self.target.ip
155
+ # Skip an unset/unspecified IP so it never yields a spurious name.
156
+ ip_name = str(ip) if ip and str(ip) not in ("0.0.0.0", "::") else ""
157
+ names = []
158
+ for version in [
159
+ self.target.mac.as_str("-"),
160
+ self.target.hostname,
161
+ ip_name,
162
+ ]:
163
+ if version:
164
+ for suffix in suffixes:
165
+ names.append(f"{version}.{suffix}")
166
+ for suffix in suffixes:
167
+ names.append(suffix)
168
+
169
+ return names
170
+
171
+ @property
172
+ def searchpaths(self) -> list[Path]:
173
+ return [*self.target.template_path, *self.image.template_path]
174
+
175
+ def render(self, filename: str, strict=True):
176
+ # Each PixieContext owns its own Renderer (built in make_context), so
177
+ # setting globals["ctx"] here is per-context; do not share one Renderer
178
+ # across contexts or nested renders would clobber this.
179
+ self._renderer.globals["ctx"] = self
180
+ try:
181
+ template = self._renderer.get_template(filename)
182
+ return template.render()
183
+ except Exception as e:
184
+ if strict:
185
+ raise e
186
+ else:
187
+ LOGGER.debug(
188
+ f"While rendering [{filename}] encountered an exeption:\t{repr(e)}"
189
+ )
190
+ return None
191
+
192
+
193
+ class PixieEvent(StrEnum):
194
+ NewPixieObject = "PixieEvent.NewPixieObject"
195
+ StartPixieInit = "PixieEvent.StartPixieInit"
196
+ SetPixieProperty = "PixieEvent.SetPixieProperty"
197
+ PixieInitiated = "PixieEvent.PixieInitiated"
198
+ LookupTarget = "PixieEvent.LookupTarget"
199
+ FoundTarget = "PixieEvent.FoundTarget"
200
+ FoundTargetImage = "PixieEvent.FoundTargetImage"
201
+ FoundTargetDhcpzone = "PixieEvent.FoundTargetDhcpzone"
202
+ PixieContextForTarget = "PixieEvent.PixieContextForTarget"
203
+ StartPixieInitialize = "PixieEvent.StartPixieInitialize"
204
+ EndPixieInitialize = "PixieEvent.EndPixieInitialize"
205
+ StartPixieComplete = "PixieEvent.StartPixieComplete"
206
+ EndPixieComplete = "PixieEvent.EndPixieComplete"
207
+
208
+
209
+ _PixieHook = _ty.Callable[["PixieEvent", "Pixie", T, dict], T]
210
+
211
+
212
+ class Pixie:
213
+ targets: "dict[str,PixieTarget]"
214
+ dhcpzones: "dict[str,DhcpZone]"
215
+ images: "dict[str, PixieImage]"
216
+ repos: "dict[str,Repository]"
217
+ globals: dict[str, object]
218
+ _ctxcls: PixieContext = PixieContext
219
+ VERSION = __version__
220
+ _config: dict
221
+ _hooks: list[_PixieHook] = []
222
+
223
+ def hook(
224
+ self: "Pixie|_ty.Sequence[_PixieHook]",
225
+ event: PixieEvent,
226
+ __value: T = None,
227
+ /,
228
+ **kwargs,
229
+ ):
230
+ LOGGER.debug(f"Running Hooks for: {event}")
231
+ if isinstance(self, Pixie):
232
+ netboot = self
233
+ hooks = self._hooks
234
+ else:
235
+ netboot = None
236
+ hooks = self
237
+ for hook in hooks:
238
+ __value = hook(event, netboot, __value, kwargs)
239
+ return __value
240
+
241
+ def __new__(
242
+ cls,
243
+ /,
244
+ hooks: _ty.Sequence[_PixieHook] = [],
245
+ **config,
246
+ ):
247
+
248
+ hooks = [(hook if callable(hook) else import_(hook)) for hook in hooks]
249
+ cls = Pixie.hook(hooks, PixieEvent.NewPixieObject, cls, config=config)
250
+ inst = object.__new__(cls)
251
+ inst._hooks = hooks
252
+ return inst
253
+
254
+ def __init__(
255
+ netboot,
256
+ /,
257
+ **config,
258
+ ):
259
+ netboot._config = netboot.hook(PixieEvent.StartPixieInit, config)
260
+ netboot.globals = deepcopy(config.get("globals") or {})
261
+ defaults = config.get("defaults") or {}
262
+
263
+ for prop, hint in get_type_hints(netboot.__class__).items():
264
+ if prop.startswith("_"):
265
+ continue
266
+ origin = _ty.get_origin(hint) or hint
267
+ value = config.get(prop)
268
+ prop_defaults = defaults.get(prop)
269
+ if issubclass(origin, dict):
270
+ value: dict[str] = value or {}
271
+ _keycls, _valcls = _ty.get_args(hint)
272
+ _value = {}
273
+ if _valcls is object:
274
+ _valctr = lambda _id, val: val
275
+ else:
276
+
277
+ def _valctr(_id, val):
278
+ val = val if isinstance(val, Mapping) else val.__dict__
279
+ if prop_defaults:
280
+ _val = deepcopy(prop_defaults)
281
+ _val.update(val)
282
+ val = _val
283
+ try:
284
+ return _valcls(_id=_id, **val)
285
+ except TypeError:
286
+ # _valcls doesn't accept an _id kwarg; build without.
287
+ return _valcls(**val)
288
+
289
+ for uid, val in value.items():
290
+ if not str(uid).startswith("_"):
291
+ _value[_keycls(uid)] = _valctr(uid, val)
292
+ else:
293
+ _value = hint(value) if value is not None else None
294
+ prop, value = netboot.hook(
295
+ PixieEvent.SetPixieProperty, (prop, _value), origin=origin, rawvalue=value
296
+ )
297
+ setattr(netboot, prop, value)
298
+
299
+ netboot.hook(PixieEvent.PixieInitiated)
300
+
301
+ def lookup_target(self, target: str) -> PixieTarget:
302
+ target: Union[str, PixieTarget] = self.hook(PixieEvent.LookupTarget, target)
303
+ if isinstance(target, str):
304
+ lower: str = target.lower()
305
+ _target = self.targets.get(target, None)
306
+ if _target is None:
307
+ for _target in self.targets.values():
308
+ if (
309
+ _target.hostname.lower().startswith(lower)
310
+ or _target.mac.as_str() == lower
311
+ or str(_target.ip) == lower
312
+ ):
313
+ target = _target
314
+ break
315
+
316
+ else:
317
+ target = _target
318
+
319
+ target = self.hook(PixieEvent.FoundTarget, target)
320
+ if not isinstance(target, PixieTarget):
321
+ target = None
322
+ return target
323
+
324
+ def lookup_image(self, name: str, target: PixieTarget = None) -> PixieImage:
325
+ imgs: list[tuple[int, PixieImage]] = [(-1, {})]
326
+ for img_name, image in self.images.items():
327
+ check = image.match(img_name, name)
328
+ if check != False:
329
+ imgs.append((check, image))
330
+ imgs.sort(key=lambda x: x[0])
331
+ return self.hook(PixieEvent.FoundTargetImage, imgs.pop()[1], target=target)
332
+
333
+ def lookup_dhcpzone(self, name: str, target: PixieTarget = None) -> DhcpZone:
334
+ if not name and target:
335
+ if target.dhcpzone:
336
+ name = target.dhcpzone
337
+ else:
338
+ for zone_id, zone in self.dhcpzones.items():
339
+ if target.ip in zone.network:
340
+ name = zone_id
341
+ target.dhcpzone = zone_id
342
+ break
343
+ zone = self.dhcpzones.get(name, None)
344
+ return self.hook(PixieEvent.FoundTargetDhcpzone, zone, target=target)
345
+
346
+ def make_context(
347
+ self, target: "PixieTarget", globals: list[dict] = None
348
+ ) -> PixieContext:
349
+ image = self.lookup_image(target.image, target)
350
+ if not image:
351
+ raise Exception(f"Image not found for target: {target.image}")
352
+ LOGGER.info(f"Found target image: {target.image}")
353
+
354
+ zone = self.lookup_dhcpzone(target.dhcpzone, target)
355
+ if not zone:
356
+ raise Exception(
357
+ f"Not supported client due to missing subnet: {target.dhcpzone}"
358
+ )
359
+ LOGGER.info(f"Found target dhcpzone: {target.dhcpzone}")
360
+
361
+ ctx = {
362
+ "image": image,
363
+ "dhcpzone": zone,
364
+ "target": target,
365
+ "repos": self.repos,
366
+ "resources": {},
367
+ }
368
+ # Collect object-level globals to layer into the context WITHOUT mutating
369
+ # the shared image/dhcpzone/target objects (they are reused across
370
+ # targets, so deleting their `globals` would drop them for later calls).
371
+ _globals = [self.globals, *(globals or [])]
372
+ for k in ["image", "dhcpzone", "target"]:
373
+ g = getattr(ctx[k], "globals", None)
374
+ if g:
375
+ _globals.append(g)
376
+
377
+ ctx = mergeObjects(self._ctxcls, ctx, *_globals)
378
+
379
+ ctx: PixieContext
380
+ ctx._renderer = Renderer(loader=Loader(self._config.get("templates", [])))
381
+ ctx.version = f"netboot-v{self.VERSION}"
382
+ return self.hook(PixieEvent.PixieContextForTarget, ctx, target=target)
383
+
384
+ def initialize(self, target: "PixieTarget"):
385
+ target = self.hook(PixieEvent.StartPixieInitialize, target)
386
+ ctx = self.make_context(target)
387
+ ctx = ctx.pxe_init(self)
388
+ return self.hook(PixieEvent.EndPixieInitialize, ctx)
389
+
390
+ def complete(self, target: "PixieTarget"):
391
+ target = self.hook(PixieEvent.StartPixieComplete, target)
392
+ ctx = self.make_context(target)
393
+ ctx = ctx.pxe_complete(self)
394
+ return self.hook(PixieEvent.EndPixieComplete, ctx)
netboot/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())