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.
Files changed (83) hide show
  1. {netboot-0.3.0 → netboot-0.3.1}/CHANGELOG.md +42 -1
  2. {netboot-0.3.0 → netboot-0.3.1}/PKG-INFO +1 -1
  3. {netboot-0.3.0 → netboot-0.3.1}/docs/configuration.md +30 -3
  4. {netboot-0.3.0 → netboot-0.3.1}/docs/extending.md +65 -0
  5. {netboot-0.3.0 → netboot-0.3.1}/pyproject.toml +1 -1
  6. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/AGENTS.md +35 -6
  7. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/__init__.py +40 -1
  8. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/dhcpd.py +1 -1
  9. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/dnsmasq.py +10 -3
  10. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/kea.py +1 -1
  11. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/dhcp/options.py +26 -5
  12. netboot-0.3.1/src/netboot/dhcp/windhcp.py +517 -0
  13. netboot-0.3.1/tests/test_dhcp_extras.py +243 -0
  14. netboot-0.3.1/tests/test_dhcp_windhcp.py +447 -0
  15. netboot-0.3.1/tests/test_dhcp_windhcp_powershell.py +331 -0
  16. netboot-0.3.0/src/netboot/dhcp/windhcp.py +0 -249
  17. netboot-0.3.0/tests/test_dhcp_windhcp.py +0 -230
  18. {netboot-0.3.0 → netboot-0.3.1}/.gitignore +0 -0
  19. {netboot-0.3.0 → netboot-0.3.1}/AGENTS.md +0 -0
  20. {netboot-0.3.0 → netboot-0.3.1}/LICENSE +0 -0
  21. {netboot-0.3.0 → netboot-0.3.1}/README.md +0 -0
  22. {netboot-0.3.0 → netboot-0.3.1}/benchmarks/README.md +0 -0
  23. {netboot-0.3.0 → netboot-0.3.1}/benchmarks/bench_netboot.py +0 -0
  24. {netboot-0.3.0 → netboot-0.3.1}/benchmarks/results/netboot.json +0 -0
  25. {netboot-0.3.0 → netboot-0.3.1}/docs/api.md +0 -0
  26. {netboot-0.3.0 → netboot-0.3.1}/docs/changelog.md +0 -0
  27. {netboot-0.3.0 → netboot-0.3.1}/docs/cli.md +0 -0
  28. {netboot-0.3.0 → netboot-0.3.1}/docs/index.md +0 -0
  29. {netboot-0.3.0 → netboot-0.3.1}/examples/README.md +0 -0
  30. {netboot-0.3.0 → netboot-0.3.1}/examples/config/pixie.yaml +0 -0
  31. {netboot-0.3.0 → netboot-0.3.1}/examples/plugins/recording.py +0 -0
  32. {netboot-0.3.0 → netboot-0.3.1}/examples/templates/debian/boot.cfg.shtpl +0 -0
  33. {netboot-0.3.0 → netboot-0.3.1}/examples/templates/debian/install.ks.j2 +0 -0
  34. {netboot-0.3.0 → netboot-0.3.1}/mkdocs.yml +0 -0
  35. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/__init__.py +0 -0
  36. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/__main__.py +0 -0
  37. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/_version.py +0 -0
  38. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/__init__.py +0 -0
  39. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/complete.py +0 -0
  40. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/cmds/initiate.py +0 -0
  41. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/content/__init__.py +0 -0
  42. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/engine.py +0 -0
  43. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/logging.py +0 -0
  44. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/main.py +0 -0
  45. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/py.typed +0 -0
  46. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/__init__.py +0 -0
  47. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/common.py +0 -0
  48. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/copy.py +0 -0
  49. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/data.py +0 -0
  50. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/external.py +0 -0
  51. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/handlebars.py +0 -0
  52. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/jinja.py +0 -0
  53. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/liquid.py +0 -0
  54. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/mako.py +0 -0
  55. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/mustache.py +0 -0
  56. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/templates/shell.py +0 -0
  57. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/__init__.py +0 -0
  58. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/config.py +0 -0
  59. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/dicts.py +0 -0
  60. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/misc.py +0 -0
  61. {netboot-0.3.0 → netboot-0.3.1}/src/netboot/utils/net.py +0 -0
  62. {netboot-0.3.0 → netboot-0.3.1}/tests/conftest.py +0 -0
  63. {netboot-0.3.0 → netboot-0.3.1}/tests/test_api_contracts.py +0 -0
  64. {netboot-0.3.0 → netboot-0.3.1}/tests/test_cli.py +0 -0
  65. {netboot-0.3.0 → netboot-0.3.1}/tests/test_cli_discovery.py +0 -0
  66. {netboot-0.3.0 → netboot-0.3.1}/tests/test_config_discovery.py +0 -0
  67. {netboot-0.3.0 → netboot-0.3.1}/tests/test_content.py +0 -0
  68. {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp.py +0 -0
  69. {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_dhcpd.py +0 -0
  70. {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_dnsmasq.py +0 -0
  71. {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_kea.py +0 -0
  72. {netboot-0.3.0 → netboot-0.3.1}/tests/test_dhcp_options.py +0 -0
  73. {netboot-0.3.0 → netboot-0.3.1}/tests/test_engine_robustness.py +0 -0
  74. {netboot-0.3.0 → netboot-0.3.1}/tests/test_external_engines.py +0 -0
  75. {netboot-0.3.0 → netboot-0.3.1}/tests/test_hooks.py +0 -0
  76. {netboot-0.3.0 → netboot-0.3.1}/tests/test_lookup.py +0 -0
  77. {netboot-0.3.0 → netboot-0.3.1}/tests/test_mako.py +0 -0
  78. {netboot-0.3.0 → netboot-0.3.1}/tests/test_optional_engines.py +0 -0
  79. {netboot-0.3.0 → netboot-0.3.1}/tests/test_render.py +0 -0
  80. {netboot-0.3.0 → netboot-0.3.1}/tests/test_shell_template.py +0 -0
  81. {netboot-0.3.0 → netboot-0.3.1}/tests/test_target.py +0 -0
  82. {netboot-0.3.0 → netboot-0.3.1}/tests/test_template_registry.py +0 -0
  83. {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.0...HEAD
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.0
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 own PowerShell cmdlets over ssh or WinRM | nothing over ssh; `netboot[winrm]` for WinRM |
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** needs the `DhcpServer` module on the host PowerShell runs on, and
76
- an account with DHCP-administrator rights.
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
+
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "netboot"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "PXE provisioning management: render netboot artifacts and drive DHCP for targets"
10
10
  readme = "README.md"
@@ -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`) — the `DhcpServer` PowerShell cmdlets over the system
217
- `ssh` client (default, no dependency) or WinRM (`netboot[winrm]`, password
218
- from `$PIXIE_WINDHCP_PASSWORD`). `server=` becomes `-ComputerName`. Parameters
219
- travel as a JSON payload, never interpolated into the script. The scope is the
220
- zone's network address, overridable with `scope` **on the zone**.
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.options.raw_for("dhcpd"))
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(self.optsfile, tag, _option_lines(tag, options))
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
- lines.extend(options.raw_for("dnsmasq"))
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(options.raw_for("kea"))
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
- return list(self.raw.get(backend, ()))
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
- options.raw.setdefault(key[len(RAW_PREFIX) :], []).append(value)
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 "