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/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