netboot 0.3.0__tar.gz → 0.3.1__tar.gz
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-0.3.0 → netboot-0.3.1}/CHANGELOG.md +42 -1
- {netboot-0.3.0 → netboot-0.3.1}/PKG-INFO +1 -1
- {netboot-0.3.0 → netboot-0.3.1}/docs/configuration.md +30 -3
- {netboot-0.3.0 → netboot-0.3.1}/docs/extending.md +65 -0
- {netboot-0.3.0 → netboot-0.3.1}/pyproject.toml +1 -1
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/AGENTS.md +35 -6
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/__init__.py +40 -1
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/dhcpd.py +1 -1
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/dnsmasq.py +10 -3
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/kea.py +1 -1
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/options.py +26 -5
- netboot-0.3.1/src/netboot/dhcp/windhcp.py +517 -0
- netboot-0.3.1/tests/test_dhcp_extras.py +243 -0
- netboot-0.3.1/tests/test_dhcp_windhcp.py +447 -0
- netboot-0.3.1/tests/test_dhcp_windhcp_powershell.py +331 -0
- netboot-0.3.0/src/netboot/dhcp/windhcp.py +0 -249
- netboot-0.3.0/tests/test_dhcp_windhcp.py +0 -230
- {netboot-0.3.0 → netboot-0.3.1}/.gitignore +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/AGENTS.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/LICENSE +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/README.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/benchmarks/README.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/benchmarks/bench_netboot.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/benchmarks/results/netboot.json +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/docs/api.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/docs/changelog.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/docs/cli.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/docs/index.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/examples/README.md +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/examples/config/pixie.yaml +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/examples/plugins/recording.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/examples/templates/debian/boot.cfg.shtpl +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/examples/templates/debian/install.ks.j2 +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/mkdocs.yml +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/__init__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/__main__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/_version.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/__init__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/complete.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/initiate.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/content/__init__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/engine.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/logging.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/main.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/py.typed +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/__init__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/common.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/copy.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/data.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/external.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/handlebars.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/jinja.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/liquid.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/mako.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/mustache.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/shell.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/__init__.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/config.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/dicts.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/misc.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/net.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/conftest.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_api_contracts.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_cli.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_cli_discovery.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_config_discovery.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_content.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_dhcpd.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_dnsmasq.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_kea.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_options.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_engine_robustness.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_external_engines.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_hooks.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_lookup.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_mako.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_optional_engines.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_render.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_shell_template.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_target.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_template_registry.py +0 -0
- {netboot-0.3.0 → netboot-0.3.1}/tests/test_utils.py +0 -0
|
@@ -4,6 +4,46 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`windhcp://` could not set a multi-valued option** (two name servers, two NTP
|
|
11
|
+
servers) with its default PowerShell method, and the failure took the rest of
|
|
12
|
+
the reservation with it. The value was sent comma-joined, and
|
|
13
|
+
`Set-DhcpServerv4OptionValue -Value` takes `String[]`: Windows answered
|
|
14
|
+
"Parameters for option value to be set for option ID 6 do not match with option
|
|
15
|
+
definition" and, under `ErrorActionPreference = 'Stop'`, every option after it
|
|
16
|
+
was abandoned — so the target got a router and **no boot file**. Values are now
|
|
17
|
+
sent as a list. Present since the backend shipped in 0.2.0; found by running it
|
|
18
|
+
against a real Windows Server 2025 DHCP server rather than reading the script.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **`DhcpServer.extras(ctx, phase)`** — one documented extension point for
|
|
22
|
+
backend-native fragments netboot does not model: an extra statement, an extra
|
|
23
|
+
command, or a condition such as the iPXE chainload (serve the iPXE binary to a
|
|
24
|
+
PXE ROM, the script to iPXE). It defaults to the configured `raw.<backend>`
|
|
25
|
+
fragments, and a subclass overrides it to decide per target. All four shipped
|
|
26
|
+
backends consume it, and `raw.<backend>.remove=` is new: a fragment that
|
|
27
|
+
belongs to teardown rather than to arming. For `windhcp://` a fragment may be a
|
|
28
|
+
PowerShell line or a `{"Args": [...], "Ignore": bool}` netsh command, which
|
|
29
|
+
works under either `method`.
|
|
30
|
+
- `method=netsh` **verifies what the server actually applied**. netsh exits 0 and
|
|
31
|
+
prints its success line even when it rejected what it was told — measured: a
|
|
32
|
+
`domain-name-servers` option with one unreachable address keeps the other, says
|
|
33
|
+
"not a valid DNS Server", says "Command completed successfully", exits 0. So
|
|
34
|
+
after applying, netboot reads the state back with `netsh ... dump` (the one
|
|
35
|
+
netsh output that is command syntax rather than localised prose) and fails
|
|
36
|
+
naming any option that is missing or short of values. The cmdlet method needs
|
|
37
|
+
none of this: Windows raises there by itself.
|
|
38
|
+
- `windhcp://` takes **`method=powershell|netsh`**. The default is unchanged (the
|
|
39
|
+
`DhcpServer` cmdlets); `method=netsh` drives `netsh dhcp server ...` instead,
|
|
40
|
+
for a host where that PowerShell module is not installed. It is independent of
|
|
41
|
+
`transport=`, and netsh is invoked from PowerShell with each argument an array
|
|
42
|
+
element, so no value is spliced into a command line. Success is `$LASTEXITCODE`
|
|
43
|
+
rather than netsh's localised success line, a removal tolerates a reservation
|
|
44
|
+
that is not there, option data types are declared per option (unmapped ones as
|
|
45
|
+
`STRING`), and a multi-valued option becomes one argument per value.
|
|
46
|
+
|
|
7
47
|
## [0.3.0] - 2026-10-02
|
|
8
48
|
|
|
9
49
|
### Changed
|
|
@@ -631,7 +671,8 @@ First packaged release: the `netboot` library with the `pixie` command line.
|
|
|
631
671
|
config value construction no longer swallows non-`TypeError` errors; repo
|
|
632
672
|
`joinpath` keeps `.local` a path so chained joins work.
|
|
633
673
|
|
|
634
|
-
[Unreleased]: https://github.com/jose-pr/netboot/compare/v0.3.
|
|
674
|
+
[Unreleased]: https://github.com/jose-pr/netboot/compare/v0.3.1...HEAD
|
|
675
|
+
[0.3.1]: https://github.com/jose-pr/netboot/compare/v0.3.0...v0.3.1
|
|
635
676
|
[0.3.0]: https://github.com/jose-pr/netboot/compare/v0.2.4...v0.3.0
|
|
636
677
|
[0.2.4]: https://github.com/jose-pr/netboot/compare/v0.2.3...v0.2.4
|
|
637
678
|
[0.2.3]: https://github.com/jose-pr/netboot/compare/v0.2.2...v0.2.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: netboot
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: PXE provisioning management: render netboot artifacts and drive DHCP for targets
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/netboot/
|
|
6
6
|
Project-URL: Issues, https://github.com/jose-pr/netboot/issues
|
|
@@ -60,7 +60,7 @@ DHCP options the server should give the client.
|
|
|
60
60
|
| `dnsmasq://` | dnsmasq | writes `dhcp-host`/`dhcp-option` files, local or over `sftp://` | nothing locally; `netboot[ssh]` for remote paths |
|
|
61
61
|
| `kea://`, `keas://` | ISC Kea | `reservation-add` / `reservation-del` via the control agent | `netboot[kea]` |
|
|
62
62
|
| `dhcpd://` | ISC dhcpd | an OMAPI host object | `netboot[dhcpd]` |
|
|
63
|
-
| `windhcp://` | Windows DHCP | its
|
|
63
|
+
| `windhcp://` | Windows DHCP | its PowerShell cmdlets *or* `netsh`, over ssh or WinRM | nothing over ssh; `netboot[winrm]` for WinRM |
|
|
64
64
|
|
|
65
65
|
Each has one thing that is easy to get wrong:
|
|
66
66
|
|
|
@@ -72,8 +72,35 @@ Each has one thing that is easy to get wrong:
|
|
|
72
72
|
file-only Kea loads the hook and still refuses.
|
|
73
73
|
- **dhcpd** hosts added over OMAPI do not survive a restart by themselves — that
|
|
74
74
|
is dhcpd's design.
|
|
75
|
-
- **Windows**
|
|
76
|
-
|
|
75
|
+
- **Windows** validates some option values as it stores them, whichever method
|
|
76
|
+
you use: a name server that does not answer is refused rather than stored. An
|
|
77
|
+
arm that fails part-way leaves the reservation with the options applied so far
|
|
78
|
+
— the next successful arm fixes it, and `pixie complete` removes it.
|
|
79
|
+
- **Windows** needs an account with DHCP-administrator rights, and — for the
|
|
80
|
+
default `method=powershell` — the `DhcpServer` module on the host the shell runs
|
|
81
|
+
on. Where that module is missing (Server Core without the RSAT feature, or an
|
|
82
|
+
older release) `method=netsh` drives `netsh dhcp server ...` instead. The two
|
|
83
|
+
choices are independent: `transport=` is how netboot gets a shell (`ssh` or
|
|
84
|
+
`winrm`), `method=` is what it runs there.
|
|
85
|
+
|
|
86
|
+
netsh is still invoked *from* PowerShell, with each argument an element of a
|
|
87
|
+
JSON array — so no value is ever spliced into a command line, which is the same
|
|
88
|
+
guarantee the cmdlet path gives. Extra commands (a policy, say) go through
|
|
89
|
+
`raw.windhcp=` or a subclass's `extras()`; see
|
|
90
|
+
[Extra commands and conditions](extending.md#extra-commands-and-conditions),
|
|
91
|
+
which is also where the iPXE chainload recipe lives.
|
|
92
|
+
|
|
93
|
+
**netsh cannot be trusted to report failure.** Given an option value it dislikes
|
|
94
|
+
— a name server that does not answer — it keeps the rest, prints "not a valid DNS
|
|
95
|
+
Server", prints "Command completed successfully" and exits 0 (measured on
|
|
96
|
+
Windows Server 2025). So netboot reads the state back afterwards with
|
|
97
|
+
`netsh ... dump`, the one netsh output that is command syntax rather than
|
|
98
|
+
localised prose, and fails naming any option that did not land or lost values.
|
|
99
|
+
The cmdlet method needs no such check: Windows raises there by itself. A few behaviours are
|
|
100
|
+
netsh's own rather than netboot's: an option's data type has to be declared
|
|
101
|
+
(netboot maps the modelled options, and sends anything else as `STRING`), a
|
|
102
|
+
multi-valued option is passed as one argument per value, and the reservation is
|
|
103
|
+
created as `BOTH` (DHCP and BOOTP) to match what the cmdlets do by default.
|
|
77
104
|
|
|
78
105
|
### Options
|
|
79
106
|
|
|
@@ -148,3 +148,68 @@ base. Import your plugin module before the config builds the zones — pass
|
|
|
148
148
|
`DhcpServer` subclass is registered when `dnsmasq://...` is resolved.
|
|
149
149
|
|
|
150
150
|
An unknown scheme raises a clear `ValueError` rather than silently doing nothing.
|
|
151
|
+
|
|
152
|
+
### Extra commands and conditions
|
|
153
|
+
|
|
154
|
+
netboot models the options it can translate everywhere, and `extras()` is the way
|
|
155
|
+
in for everything else — an extra statement, an extra command, or a condition
|
|
156
|
+
that makes the server answer two kinds of client differently. The canonical case
|
|
157
|
+
is the **iPXE chainload**: hand a PXE ROM the iPXE binary, then hand iPXE itself
|
|
158
|
+
the script, or iPXE loads iPXE forever.
|
|
159
|
+
|
|
160
|
+
Two ways in, both ending up in the same list:
|
|
161
|
+
|
|
162
|
+
```yaml
|
|
163
|
+
dhcpservers:
|
|
164
|
+
# from config, per connection
|
|
165
|
+
- dhcpd://key@dhcp01/?raw.dhcpd=if exists user-class and option user-class = "iPXE" { filename "boot.ipxe"; } else { filename "undionly.kpxe"; }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# from code, which is the one that can look at the target
|
|
170
|
+
from netboot.dhcp.dhcpd import dhcpd
|
|
171
|
+
|
|
172
|
+
class dhcpd(dhcpd): # still handles dhcpd://
|
|
173
|
+
def extras(self, ctx, phase="add"):
|
|
174
|
+
lines = super().extras(ctx, phase) # keep the config's own fragments
|
|
175
|
+
if phase == "add" and ctx.image.globals.get("ipxe"):
|
|
176
|
+
lines.append(
|
|
177
|
+
'if exists user-class and option user-class = "iPXE" '
|
|
178
|
+
'{ filename "boot.ipxe"; } else { filename "undionly.kpxe"; }'
|
|
179
|
+
)
|
|
180
|
+
return lines
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`phase` is `add` or `remove`. A bare `raw.<backend>=` belongs to `add`;
|
|
184
|
+
`raw.<backend>.remove=` is for a fragment that undoes something at teardown (a
|
|
185
|
+
policy created while arming, say). What a fragment *is* belongs to the backend:
|
|
186
|
+
|
|
187
|
+
| Backend | A fragment is | Where it lands |
|
|
188
|
+
| ------- | ------------- | -------------- |
|
|
189
|
+
| `dhcpd://` | dhcpd config **statements** | appended to the host object's `statements`, after the modelled options — so a conditional written there wins |
|
|
190
|
+
| `dnsmasq://` | config **lines** (`dhcp-match=set:ipxe,77,iPXE`, `dhcp-boot=tag:ipxe,boot.ipxe`) | the options file for that target's tag |
|
|
191
|
+
| `kea://` | an **`option-data` entry** | the reservation's `option-data` |
|
|
192
|
+
| `windhcp://` | a **PowerShell line**, or a `{"Args": [...], "Ignore": false}` **netsh command** | after the reservation and its options |
|
|
193
|
+
|
|
194
|
+
A netsh command works under either windhcp `method`: with `method=netsh` it joins
|
|
195
|
+
the command list, and with `method=powershell` it becomes the `netsh` call it
|
|
196
|
+
describes rather than being dropped. `Args` may be a list, or one string that is
|
|
197
|
+
split on whitespace — a query string has nowhere to put a list. `Ignore` marks a
|
|
198
|
+
command whose failure is acceptable, which is what a teardown usually wants.
|
|
199
|
+
|
|
200
|
+
Fragments are emitted **verbatim**: netboot does not parse, validate or escape
|
|
201
|
+
them. That is the point of an escape hatch, and its risk — a value that ends a
|
|
202
|
+
dhcpd statement early, or a PowerShell line that does more than it looks like,
|
|
203
|
+
is yours to get right. Where netboot builds the text itself (a netsh command
|
|
204
|
+
under `method=powershell`) it quotes it properly.
|
|
205
|
+
|
|
206
|
+
Order is defined: modelled options first, then `extras()`, in the order given
|
|
207
|
+
(unsuffixed `raw.<backend>` before `raw.<backend>.add`). A condition that
|
|
208
|
+
overrides an option therefore comes after the thing it overrides, which is what
|
|
209
|
+
both dhcpd's last-write-wins statements and a Windows policy need.
|
|
210
|
+
|
|
211
|
+
A declarative, cross-backend way to say "serve this file to iPXE and that one to
|
|
212
|
+
a PXE ROM" is not here yet, because the backends disagree about where a condition
|
|
213
|
+
lives — dhcpd puts it on the host, Windows in a scope-level policy, dnsmasq in a
|
|
214
|
+
tag, Kea in a client class. `extras()` is what makes it expressible today.
|
|
215
|
+
|
|
@@ -212,16 +212,45 @@ options from the URI query (see below) and translates them.
|
|
|
212
212
|
`netboot[dhcpd]`) — an OMAPI host object whose `statements` carry the options,
|
|
213
213
|
escaped. Secret from `keyfile=` or `$PIXIE_DHCPD_OMAPI_KEY`. A host added this
|
|
214
214
|
way does **not** survive a dhcpd restart by itself.
|
|
215
|
-
- **`windhcp://[user@]host/?transport=ssh|winrm&server=&auth=&port=&ssl=`**
|
|
216
|
-
(`netboot.dhcp.windhcp`) —
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
215
|
+
- **`windhcp://[user@]host/?transport=ssh|winrm&method=powershell|netsh&server=&auth=&port=&ssl=`**
|
|
216
|
+
(`netboot.dhcp.windhcp`) — Windows DHCP over the system `ssh` client (default,
|
|
217
|
+
no dependency) or WinRM (`netboot[winrm]`, password from
|
|
218
|
+
`$PIXIE_WINDHCP_PASSWORD`). **`transport=` is how we get a shell, `method=` is
|
|
219
|
+
what runs there**: `powershell` (default) for the `DhcpServer` module's
|
|
220
|
+
cmdlets, `netsh` for `netsh dhcp server ...` on a host without that module.
|
|
221
|
+
`server=` becomes `-ComputerName`, or netsh's `\\server` (already-`\\`-prefixed
|
|
222
|
+
is left alone). An option's value is always a **list** in the payload, because
|
|
223
|
+
`Set-DhcpServerv4OptionValue -Value` takes `String[]` and refuses a joined
|
|
224
|
+
`"a,b"`; netsh takes one argument per value. Under `method=netsh` the script
|
|
225
|
+
**reads the state back** (`netsh ... dump`) and fails naming any option that is
|
|
226
|
+
missing or short of values, because netsh exits 0 and claims success after
|
|
227
|
+
silently dropping a value it dislikes. Parameters travel as a JSON payload, never interpolated into
|
|
228
|
+
the script — including under `netsh`, which is invoked as
|
|
229
|
+
`& netsh @($c.Args)` so every argument stays an array element. netsh is judged
|
|
230
|
+
by `$LASTEXITCODE`, not its localised success line; a `delete` carries
|
|
231
|
+
`Ignore` so cleanup can re-run; option data types come from `_OPTION_TYPES`
|
|
232
|
+
(unlisted → `STRING`), a multi-valued option becomes one argument per value
|
|
233
|
+
(the cmdlets take it comma-joined instead), and a reservation is added as
|
|
234
|
+
`BOTH`. The scope is the zone's network address, overridable with `scope`
|
|
235
|
+
**on the zone**.
|
|
221
236
|
|
|
222
237
|
Each raises `PixieLookupError` for a target with no MAC — every one of these
|
|
223
238
|
servers identifies a reservation by it.
|
|
224
239
|
|
|
240
|
+
- **`DhcpServer.extras(ctx, phase="add") -> list`** — the extension point for
|
|
241
|
+
anything netboot does not model: an extra statement, an extra command, or a
|
|
242
|
+
condition (the iPXE chainload is the usual one). The default returns the
|
|
243
|
+
configured `raw.<backend>` fragments for that phase; a subclass overrides it to
|
|
244
|
+
decide **per target**, and should call `super()` to keep the config's own. The
|
|
245
|
+
fragment shape is the backend's: dhcpd statements, dnsmasq config lines, a kea
|
|
246
|
+
`option-data` entry, or — for windhcp — a PowerShell line or a
|
|
247
|
+
`{"Args": [...], "Ignore": bool}` netsh command, which is rendered as a quoted
|
|
248
|
+
`netsh` call when the method is `powershell` rather than dropped. `phase` is
|
|
249
|
+
`add` or `remove` (**`PHASES`**); a bare `raw.<backend>` is `add`,
|
|
250
|
+
`raw.<backend>.remove` is teardown. Fragments are emitted **verbatim** — no
|
|
251
|
+
parsing, validation or escaping — and always **after** the modelled options,
|
|
252
|
+
so a conditional overrides them.
|
|
253
|
+
|
|
225
254
|
## DHCP options (`netboot.dhcp.options`)
|
|
226
255
|
|
|
227
256
|
- **`GENERIC_OPTIONS`** — the option names netboot models and every backend
|
|
@@ -13,7 +13,7 @@ if _ty.TYPE_CHECKING:
|
|
|
13
13
|
|
|
14
14
|
from urllib.parse import urlparse
|
|
15
15
|
|
|
16
|
-
from .options import DhcpOptions, build_options, split_query
|
|
16
|
+
from .options import PHASES, DhcpOptions, build_options, split_query
|
|
17
17
|
|
|
18
18
|
#: Backends netboot ships, and the extra each needs. Used only to turn "no
|
|
19
19
|
#: backend for scheme" into a message that names the fix.
|
|
@@ -115,6 +115,45 @@ class DhcpServer:
|
|
|
115
115
|
"""The client options for this target on this server, fully merged."""
|
|
116
116
|
return build_options(ctx, self)
|
|
117
117
|
|
|
118
|
+
def extras(self, ctx, phase: str = "add") -> "list":
|
|
119
|
+
"""Backend-native fragments to apply along with the reservation.
|
|
120
|
+
|
|
121
|
+
This is the extension point for anything netboot does not model: an
|
|
122
|
+
extra statement, an extra command, a conditional that makes the server
|
|
123
|
+
answer two kinds of client differently (the iPXE chainload is the usual
|
|
124
|
+
one -- hand a PXE ROM the iPXE binary, then hand iPXE the script).
|
|
125
|
+
|
|
126
|
+
Two ways in, and they end up in the same list:
|
|
127
|
+
|
|
128
|
+
- **from config**, `raw.<backend>=...` on the connection, or
|
|
129
|
+
`raw.<backend>.remove=...` for teardown;
|
|
130
|
+
- **from code**, by overriding this method in a `DhcpServer` subclass,
|
|
131
|
+
which is the one that can look at `ctx` and decide per target:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
class dhcpd(netboot.dhcp.dhcpd.dhcpd):
|
|
135
|
+
def extras(self, ctx, phase="add"):
|
|
136
|
+
lines = super().extras(ctx, phase)
|
|
137
|
+
if phase == "add" and ctx.image._id.startswith("ipxe"):
|
|
138
|
+
lines.append('if exists user-class ... { filename "boot.ipxe"; }')
|
|
139
|
+
return lines
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
What a fragment *is* belongs to the backend and is documented there:
|
|
143
|
+
dhcpd takes config statements, dnsmasq config lines, kea `option-data`
|
|
144
|
+
entries, windhcp PowerShell lines (or, under `method=netsh`, a
|
|
145
|
+
`{"Args": [...], "Ignore": bool}` command). A backend calls this once
|
|
146
|
+
per phase and emits whatever comes back **verbatim** -- netboot does not
|
|
147
|
+
parse, validate or escape it, which is the point of an escape hatch and
|
|
148
|
+
also the risk of one.
|
|
149
|
+
"""
|
|
150
|
+
if phase not in PHASES:
|
|
151
|
+
raise ValueError(f"phase must be one of {PHASES}, not {phase!r}")
|
|
152
|
+
options = getattr(self, "options", None)
|
|
153
|
+
if options is None: # pragma: no cover - a subclass that skips __init__
|
|
154
|
+
return []
|
|
155
|
+
return options.raw_for(type(self).__name__, phase)
|
|
156
|
+
|
|
118
157
|
def remove_target(self, netboot: "PixieContext"):
|
|
119
158
|
"""Disarm this backend for the target in `netboot` (a `PixieContext`)."""
|
|
120
159
|
pass
|
|
@@ -109,7 +109,7 @@ class dhcpd(DhcpServer): # noqa: N801 - the class name is the URI scheme
|
|
|
109
109
|
target = netboot.target
|
|
110
110
|
mac = _mac(target)
|
|
111
111
|
options = self.options_for(netboot)
|
|
112
|
-
statements = render_statements(options, self.
|
|
112
|
+
statements = render_statements(options, self.extras(netboot, "add"))
|
|
113
113
|
connection = self.connect()
|
|
114
114
|
try:
|
|
115
115
|
connection.add_host_supersede(
|
|
@@ -95,7 +95,11 @@ class dnsmasq(DhcpServer): # noqa: N801 - the class name is the URI scheme
|
|
|
95
95
|
tag = _tag(target)
|
|
96
96
|
self._write(self.hostsfile, tag, [_host_line(target, tag)])
|
|
97
97
|
if self.optsfile is not None:
|
|
98
|
-
self._write(
|
|
98
|
+
self._write(
|
|
99
|
+
self.optsfile,
|
|
100
|
+
tag,
|
|
101
|
+
_option_lines(tag, options, self.extras(netboot, "add")),
|
|
102
|
+
)
|
|
99
103
|
self._reload()
|
|
100
104
|
|
|
101
105
|
def remove_target(self, netboot: "_ty.Any"):
|
|
@@ -226,7 +230,7 @@ def _host_line(target, tag: str) -> str:
|
|
|
226
230
|
return "dhcp-host=" + ",".join(fields)
|
|
227
231
|
|
|
228
232
|
|
|
229
|
-
def _option_lines(tag: str, options) -> "list[str]":
|
|
233
|
+
def _option_lines(tag: str, options, extras: "list[str]|None" = None) -> "list[str]":
|
|
230
234
|
"""Translate the generic options into dnsmasq's own syntax."""
|
|
231
235
|
lines: "list[str]" = []
|
|
232
236
|
boot_file = options.get("boot-file-name")
|
|
@@ -244,7 +248,10 @@ def _option_lines(tag: str, options) -> "list[str]":
|
|
|
244
248
|
if code is None:
|
|
245
249
|
code = name[len("option-") :] if name.startswith("option-") else name
|
|
246
250
|
lines.append(f"dhcp-option=tag:{tag},{code},{_value(value)}")
|
|
247
|
-
|
|
251
|
+
# `extras` comes from the server's `extras()` hook when there is one, so a
|
|
252
|
+
# subclass can add a `dhcp-match`/`dhcp-boot` pair per target; falling back
|
|
253
|
+
# to the options' own raw lines keeps this usable without a server.
|
|
254
|
+
lines.extend(extras if extras is not None else options.raw_for("dnsmasq"))
|
|
248
255
|
return lines
|
|
249
256
|
|
|
250
257
|
|
|
@@ -145,7 +145,7 @@ class kea(DhcpServer): # noqa: N801 - the class name is the URI scheme
|
|
|
145
145
|
option_data.append({"code": int(code), "data": _value(value)})
|
|
146
146
|
else:
|
|
147
147
|
option_data.append({"name": name, "data": _value(value)})
|
|
148
|
-
option_data.extend(
|
|
148
|
+
option_data.extend(self.extras(ctx, "add"))
|
|
149
149
|
if option_data:
|
|
150
150
|
reservation["option-data"] = option_data
|
|
151
151
|
return reservation
|
|
@@ -59,9 +59,17 @@ APPLY_TIME = {
|
|
|
59
59
|
OPTIONS_BUILDER = "options_builder"
|
|
60
60
|
|
|
61
61
|
#: Query-key prefix for backend-native text netboot never translates:
|
|
62
|
-
#: ``raw.dnsmasq=dhcp-option=tag:x,66,10.0.0.2``.
|
|
62
|
+
#: ``raw.dnsmasq=dhcp-option=tag:x,66,10.0.0.2``. A phase may be appended --
|
|
63
|
+
#: ``raw.dhcpd.remove=...`` -- for a fragment that belongs to teardown rather
|
|
64
|
+
#: than to arming; a bare ``raw.<backend>`` is ``add``.
|
|
63
65
|
RAW_PREFIX = "raw."
|
|
64
66
|
|
|
67
|
+
#: The phases a raw fragment (or a backend's `extras()`) can belong to.
|
|
68
|
+
PHASES = ("add", "remove")
|
|
69
|
+
|
|
70
|
+
#: Separator in a `raw` key between backend and phase.
|
|
71
|
+
RAW_PHASE_SEP = "."
|
|
72
|
+
|
|
65
73
|
|
|
66
74
|
def is_option(name: str) -> bool:
|
|
67
75
|
"""Is `name` a client option netboot can carry to some backend?"""
|
|
@@ -83,9 +91,17 @@ class DhcpOptions(dict):
|
|
|
83
91
|
super().__init__(*args, **kwargs)
|
|
84
92
|
self.raw: "dict[str, list[str]]" = {k: list(v) for k, v in (raw or {}).items()}
|
|
85
93
|
|
|
86
|
-
def raw_for(self, backend: str) -> "list[str]":
|
|
87
|
-
"""The untranslated lines this backend should emit verbatim.
|
|
88
|
-
|
|
94
|
+
def raw_for(self, backend: str, phase: str = "add") -> "list[str]":
|
|
95
|
+
"""The untranslated lines this backend should emit verbatim.
|
|
96
|
+
|
|
97
|
+
`phase` is `add` or `remove`. A key with no phase (`raw.dhcpd=`) belongs
|
|
98
|
+
to `add`, which is what a fragment almost always is -- an extra
|
|
99
|
+
statement or command applied along with the reservation.
|
|
100
|
+
"""
|
|
101
|
+
lines = list(self.raw.get(f"{backend}{RAW_PHASE_SEP}{phase}", ()))
|
|
102
|
+
if phase == "add":
|
|
103
|
+
lines = list(self.raw.get(backend, ())) + lines
|
|
104
|
+
return lines
|
|
89
105
|
|
|
90
106
|
def copy(self) -> "DhcpOptions":
|
|
91
107
|
return DhcpOptions(self, raw=self.raw)
|
|
@@ -111,7 +127,12 @@ def split_query(uri: str, settings: "_ty.Iterable[str]", backend: str):
|
|
|
111
127
|
elif key == OPTIONS_BUILDER:
|
|
112
128
|
builder = value
|
|
113
129
|
elif key.startswith(RAW_PREFIX):
|
|
114
|
-
|
|
130
|
+
name = key[len(RAW_PREFIX) :]
|
|
131
|
+
backend_name, _, phase = name.rpartition(RAW_PHASE_SEP)
|
|
132
|
+
if backend_name and phase in PHASES:
|
|
133
|
+
# `raw.dhcpd.remove=...` -- a teardown fragment.
|
|
134
|
+
name = f"{backend_name}{RAW_PHASE_SEP}{phase}"
|
|
135
|
+
options.raw.setdefault(name, []).append(value)
|
|
115
136
|
elif key in APPLY_TIME:
|
|
116
137
|
raise PixieConfigError(
|
|
117
138
|
f"{key!r} cannot be set on a {backend} connection: it depends on "
|