netboot 0.2.0__tar.gz → 0.2.2__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 (67) hide show
  1. {netboot-0.2.0 → netboot-0.2.2}/CHANGELOG.md +99 -1
  2. {netboot-0.2.0 → netboot-0.2.2}/PKG-INFO +23 -7
  3. {netboot-0.2.0 → netboot-0.2.2}/README.md +10 -4
  4. {netboot-0.2.0 → netboot-0.2.2}/docs/configuration.md +64 -5
  5. {netboot-0.2.0 → netboot-0.2.2}/docs/extending.md +7 -0
  6. {netboot-0.2.0 → netboot-0.2.2}/docs/index.md +4 -0
  7. {netboot-0.2.0 → netboot-0.2.2}/examples/README.md +5 -0
  8. {netboot-0.2.0 → netboot-0.2.2}/examples/config/pixie.yaml +11 -1
  9. {netboot-0.2.0 → netboot-0.2.2}/pyproject.toml +30 -9
  10. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/AGENTS.md +80 -9
  11. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/content/__init__.py +26 -19
  12. netboot-0.2.0/src/netboot/dhcp.py → netboot-0.2.2/src/netboot/dhcp/__init__.py +57 -5
  13. netboot-0.2.2/src/netboot/dhcp/dhcpd.py +204 -0
  14. netboot-0.2.2/src/netboot/dhcp/dnsmasq.py +311 -0
  15. netboot-0.2.2/src/netboot/dhcp/kea.py +209 -0
  16. netboot-0.2.2/src/netboot/dhcp/options.py +226 -0
  17. netboot-0.2.2/src/netboot/dhcp/windhcp.py +249 -0
  18. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/engine.py +4 -0
  19. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/utils/net.py +2 -2
  20. {netboot-0.2.0 → netboot-0.2.2}/tests/test_config_discovery.py +19 -0
  21. {netboot-0.2.0 → netboot-0.2.2}/tests/test_content.py +7 -3
  22. netboot-0.2.2/tests/test_dhcp_dhcpd.py +211 -0
  23. netboot-0.2.2/tests/test_dhcp_dnsmasq.py +225 -0
  24. netboot-0.2.2/tests/test_dhcp_kea.py +236 -0
  25. netboot-0.2.2/tests/test_dhcp_options.py +270 -0
  26. netboot-0.2.2/tests/test_dhcp_windhcp.py +230 -0
  27. {netboot-0.2.0 → netboot-0.2.2}/.gitignore +0 -0
  28. {netboot-0.2.0 → netboot-0.2.2}/AGENTS.md +0 -0
  29. {netboot-0.2.0 → netboot-0.2.2}/LICENSE +0 -0
  30. {netboot-0.2.0 → netboot-0.2.2}/benchmarks/README.md +0 -0
  31. {netboot-0.2.0 → netboot-0.2.2}/benchmarks/bench_netboot.py +0 -0
  32. {netboot-0.2.0 → netboot-0.2.2}/benchmarks/results/netboot.json +0 -0
  33. {netboot-0.2.0 → netboot-0.2.2}/docs/api.md +0 -0
  34. {netboot-0.2.0 → netboot-0.2.2}/docs/changelog.md +0 -0
  35. {netboot-0.2.0 → netboot-0.2.2}/docs/cli.md +0 -0
  36. {netboot-0.2.0 → netboot-0.2.2}/examples/plugins/recording.py +0 -0
  37. {netboot-0.2.0 → netboot-0.2.2}/examples/templates/debian/boot.cfg +0 -0
  38. {netboot-0.2.0 → netboot-0.2.2}/examples/templates/debian/install.ks.j2 +0 -0
  39. {netboot-0.2.0 → netboot-0.2.2}/mkdocs.yml +0 -0
  40. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/__init__.py +0 -0
  41. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/__main__.py +0 -0
  42. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/_version.py +0 -0
  43. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/cmds/__init__.py +0 -0
  44. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/cmds/complete.py +0 -0
  45. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/cmds/initiate.py +0 -0
  46. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/logging.py +0 -0
  47. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/main.py +0 -0
  48. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/py.typed +0 -0
  49. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/templates/__init__.py +0 -0
  50. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/templates/common.py +0 -0
  51. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/templates/jinja.py +0 -0
  52. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/templates/shell.py +0 -0
  53. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/utils/__init__.py +0 -0
  54. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/utils/config.py +0 -0
  55. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/utils/dicts.py +0 -0
  56. {netboot-0.2.0 → netboot-0.2.2}/src/netboot/utils/misc.py +0 -0
  57. {netboot-0.2.0 → netboot-0.2.2}/tests/conftest.py +0 -0
  58. {netboot-0.2.0 → netboot-0.2.2}/tests/test_api_contracts.py +0 -0
  59. {netboot-0.2.0 → netboot-0.2.2}/tests/test_cli.py +0 -0
  60. {netboot-0.2.0 → netboot-0.2.2}/tests/test_cli_discovery.py +0 -0
  61. {netboot-0.2.0 → netboot-0.2.2}/tests/test_dhcp.py +0 -0
  62. {netboot-0.2.0 → netboot-0.2.2}/tests/test_engine_robustness.py +0 -0
  63. {netboot-0.2.0 → netboot-0.2.2}/tests/test_hooks.py +0 -0
  64. {netboot-0.2.0 → netboot-0.2.2}/tests/test_lookup.py +0 -0
  65. {netboot-0.2.0 → netboot-0.2.2}/tests/test_render.py +0 -0
  66. {netboot-0.2.0 → netboot-0.2.2}/tests/test_target.py +0 -0
  67. {netboot-0.2.0 → netboot-0.2.2}/tests/test_utils.py +0 -0
@@ -6,6 +6,102 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.2] - 2026-09-21
10
+
11
+ netboot moves to netimps 0.3.1 and yaconfiglib 0.13. If a plugin, hook or
12
+ template compares `target.mac` with text, read the `MACAddress` entry below.
13
+
14
+ ### Changed
15
+ - Dependencies move to their new series: `netimps>=0.3.1,<0.4` and
16
+ `yaconfiglib>=0.13.0,<0.14`. The old ceilings made netboot uninstallable
17
+ beside either release. netimps 0.3.0 is skipped on purpose: it raised from
18
+ `resolve()` when no resolver was reachable, which would have failed every
19
+ command at startup, and 0.3.1 answers `[]` again. netboot uses none of the
20
+ yaconfiglib APIs 0.13.0 removed.
21
+ - **`MACAddress` no longer equals a `str`** (netimps 0.3.0, re-exported as
22
+ `netboot.utils.net.MACAddress`). `target.mac == "aa:bb:cc:dd:ee:ff"` is now
23
+ `False` — in a plugin, a hook or a Jinja template alike. Compare with
24
+ `MACAddress.try_parse(text) == target.mac`. In exchange a MAC is found in a
25
+ set or dict whichever spelling built it. netboot's own target lookup already
26
+ compared parsed MACs and is unaffected. A MAC that mixes separators
27
+ (`00-11:22-33:44-55`) is now rejected, and `aa.bb.cc.dd.ee.ff` is accepted.
28
+ - `resolve()` keeps trying backends after an empty answer, so a hostname
29
+ target known only to the hosts file or NSS now gets its IP on Windows and
30
+ macOS. A name that exists nowhere costs two or three lookups instead of one.
31
+
32
+ ## [0.2.1] - 2026-09-17
33
+
34
+ netboot ships DHCP backends. Additive: nothing documented changed behaviour,
35
+ and a config that named no `dhcpservers` scheme netboot implements works as
36
+ before.
37
+
38
+ ### Fixed
39
+ - A repository `address` of the form `host:port` no longer sends the whole
40
+ string to DNS. The port is split off first, so `mirror.example:8080`
41
+ resolves `mirror.example` — the old lookup could only fail, and did so at
42
+ different speeds on different machines.
43
+
44
+ ### Added
45
+ - `netboot.dhcp` is a package, and DHCP backends carry **client options**. A
46
+ `dhcpservers` entry is still a plain URI string; its query string now holds the
47
+ options the server should give the client (`?router=10.0.0.1`), alongside each
48
+ backend's own connection settings. `netboot.dhcp.options` models them:
49
+ `GENERIC_OPTIONS` for the names netboot translates, `option-<n>` for anything
50
+ else, `raw.<backend>=` for untranslated backend-native text, and
51
+ `options_builder=my.mod.fn` plus the new `PixieEvent.BuildDhcpOptions` for
52
+ building them from the context. Merge order, later winning: zone defaults, the
53
+ connection query, `image.dhcp_options`, `target.dhcp_options`, the callback,
54
+ the hook.
55
+ - **A dnsmasq backend** (`dnsmasq://`), the first netboot ships. It writes
56
+ `dhcp-host`/`dhcp-option`/`dhcp-boot` entries through `pathlib_next` paths, so
57
+ the same configuration serves a local dnsmasq or one reached over `sftp://`
58
+ (`netboot[ssh]`). `hostsfile`/`optsfile` may be a **directory** (one file per
59
+ target — preferred, and with `--dhcp-hostsdir`/`--dhcp-optsdir` dnsmasq re-reads
60
+ them with no signal at all) or a **file**, where netboot edits only its own
61
+ marked region and never touches a line it did not write. `reload=` runs a
62
+ command, over ssh when the URI names a host; omitting it is fine for a watched
63
+ directory and an error for a file, which dnsmasq never re-reads by itself.
64
+ - **A Kea backend** (`kea://`, `keas://` for https). It sends `reservation-add`
65
+ and `reservation-del` to the control agent, mapping `boot-file-name`,
66
+ `next-server` and `host-name` onto reservation *fields* and everything else
67
+ onto `option-data`. The `subnet-id` comes from the zone (`subnet_id:`) or is
68
+ discovered once by matching the zone's network against `config-get`; when
69
+ neither works netboot refuses rather than guessing, because a reservation in
70
+ the wrong subnet silently never matches. A `result: 2` from Kea is reported
71
+ with the likely cause (host_cmds not loaded, or a read-only hosts backend),
72
+ and an existing reservation for the same MAC is refused rather than
73
+ overwritten. Needs `netboot[kea]`.
74
+ - **An ISC dhcpd backend** (`dhcpd://`) over OMAPI, the only way to give dhcpd a
75
+ reservation without rewriting and reloading its config. Options are rendered
76
+ into the host's `statements`, which is dhcpd config *source*: text values are
77
+ quoted with `"` and `\` escaped, a value meant to be an address must look like
78
+ one, and a newline is refused outright — a value cannot end its statement and
79
+ start another. The OMAPI secret comes from `keyfile=` or
80
+ `$PIXIE_DHCPD_OMAPI_KEY`, never from the URI. Needs `netboot[dhcpd]`. Note
81
+ that a host added over OMAPI does not survive a dhcpd restart by itself: that
82
+ is dhcpd's design, not netboot forgetting it.
83
+ - **A Windows DHCP Server backend** (`windhcp://`), driving the `DhcpServer`
84
+ PowerShell module over **ssh** (the default, no dependency — it uses the system
85
+ `ssh` client, so your config, keys, agent and jump hosts apply) or **WinRM**
86
+ (`netboot[winrm]`). The transport host and the DHCP server are separate:
87
+ `server=` becomes `-ComputerName` when the cmdlets should act elsewhere. The
88
+ scope is the zone's network address, resolved when the target is applied and
89
+ overridable per zone. No value is interpolated into the script: parameters
90
+ travel as a JSON payload PowerShell parses, so a target name or option value
91
+ cannot become a statement.
92
+ - New extras, each carrying exactly one backend's dependency: `netboot[kea]`,
93
+ `netboot[dhcpd]`, `netboot[winrm]`, and `netboot[ssh]` for dnsmasq over
94
+ `sftp://`. A local dnsmasq and `windhcp://` over ssh need none. Verified on a
95
+ clean wheel install: importing `netboot.dhcp` loads no backend dependency,
96
+ those two backends construct, and the other two raise `ImportError` naming
97
+ their extra.
98
+ - Anything that varies per target is refused in a connection string and resolved
99
+ when the target is applied instead: `subnet_id`/`scope` belong to the zone,
100
+ `boot-file-name`/`next-server`/`tftp-server-name` to the image or target. The
101
+ error names where each belongs. `from netboot.dhcp import DhcpServer, DhcpZone`
102
+ is unchanged, and a backend module is imported only when a config names its
103
+ scheme.
104
+
9
105
  ## [0.2.0] - 2026-09-17
10
106
 
11
107
  A correctness and hardening release from a full review of the code base.
@@ -447,7 +543,9 @@ First packaged release: the `netboot` library with the `pixie` command line.
447
543
  config value construction no longer swallows non-`TypeError` errors; repo
448
544
  `joinpath` keeps `.local` a path so chained joins work.
449
545
 
450
- [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.0...HEAD
546
+ [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.2...HEAD
547
+ [0.2.2]: https://github.com/jose-pr/netboot/compare/v0.2.1...v0.2.2
548
+ [0.2.1]: https://github.com/jose-pr/netboot/compare/v0.2.0...v0.2.1
451
549
  [0.2.0]: https://github.com/jose-pr/netboot/compare/v0.1.3...v0.2.0
452
550
  [0.1.3]: https://github.com/jose-pr/netboot/compare/v0.1.2...v0.1.3
453
551
  [0.1.2]: https://github.com/jose-pr/netboot/compare/v0.1.1...v0.1.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netboot
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -27,9 +27,9 @@ Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.9
28
28
  Requires-Dist: duho<0.6,>=0.5.0
29
29
  Requires-Dist: jinja2<4,>=3.0
30
- Requires-Dist: netimps<0.3,>=0.2.1
30
+ Requires-Dist: netimps<0.4,>=0.3.1
31
31
  Requires-Dist: pathlib-next[uri]<0.10,>=0.9.9
32
- Requires-Dist: yaconfiglib<0.13,>=0.12.0
32
+ Requires-Dist: yaconfiglib<0.14,>=0.13.0
33
33
  Provides-Extra: config
34
34
  Requires-Dist: pyyaml; extra == 'config'
35
35
  Provides-Extra: dev
@@ -37,11 +37,15 @@ Requires-Dist: black; extra == 'dev'
37
37
  Requires-Dist: build; extra == 'dev'
38
38
  Requires-Dist: dnspython; extra == 'dev'
39
39
  Requires-Dist: hatchling; extra == 'dev'
40
+ Requires-Dist: pypureomapi; extra == 'dev'
40
41
  Requires-Dist: pytest; extra == 'dev'
41
42
  Requires-Dist: pytest-cov; extra == 'dev'
43
+ Requires-Dist: pywinrm; extra == 'dev'
42
44
  Requires-Dist: pyyaml; extra == 'dev'
43
45
  Requires-Dist: requests; extra == 'dev'
44
46
  Requires-Dist: twine; extra == 'dev'
47
+ Provides-Extra: dhcpd
48
+ Requires-Dist: pypureomapi; extra == 'dhcpd'
45
49
  Provides-Extra: dns
46
50
  Requires-Dist: dnspython; extra == 'dns'
47
51
  Provides-Extra: docs
@@ -50,6 +54,12 @@ Requires-Dist: mkdocs-material; extra == 'docs'
50
54
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
51
55
  Provides-Extra: http
52
56
  Requires-Dist: requests; extra == 'http'
57
+ Provides-Extra: kea
58
+ Requires-Dist: requests; extra == 'kea'
59
+ Provides-Extra: ssh
60
+ Requires-Dist: pathlib-next[sftp]; extra == 'ssh'
61
+ Provides-Extra: winrm
62
+ Requires-Dist: pywinrm; extra == 'winrm'
53
63
  Description-Content-Type: text/markdown
54
64
 
55
65
  # netboot
@@ -88,6 +98,10 @@ Optional extras:
88
98
  | `netboot[config]` | YAML config loading for the CLI (`pyyaml`) |
89
99
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
90
100
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
101
+ | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
102
+ | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
103
+ | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
104
+ | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
91
105
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
92
106
 
93
107
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
@@ -140,10 +154,12 @@ pixie.complete(target) # cleanup once installed
140
154
  `Pixie(...)`. Each hook `f(event, netboot, value, kwargs) -> value` is called for
141
155
  every `PixieEvent` and may transform the value flowing through it — used to
142
156
  customise lookup, context construction and the init/complete lifecycle.
143
- - **DHCP backends.** Subclass `netboot.dhcp.DhcpServer`; the lowercased class name
144
- is the URI scheme it handles (`class dnsmasq(DhcpServer)` → `dnsmasq://...`).
145
- Import your plugin module via `--load-module` so it is registered before the
146
- config builds the zones.
157
+ - **DHCP backends.** netboot ships four — `dnsmasq://`, `kea://`, `dhcpd://` and
158
+ `windhcp://` (Windows, over ssh or WinRM) — each imported only when a config
159
+ names its scheme. Write your own by subclassing `netboot.dhcp.DhcpServer`: the
160
+ lowercased class name is the URI scheme it handles, and `--load-module`
161
+ registers it before the config builds the zones. What the server should tell
162
+ the client rides in the URI query; see the configuration guide.
147
163
 
148
164
  A complete runnable setup — config, both template engines and a DHCP plugin —
149
165
  is in [`examples/`](https://github.com/jose-pr/netboot/tree/main/examples).
@@ -34,6 +34,10 @@ Optional extras:
34
34
  | `netboot[config]` | YAML config loading for the CLI (`pyyaml`) |
35
35
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
36
36
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
37
+ | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
38
+ | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
39
+ | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
40
+ | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
37
41
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
38
42
 
39
43
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
@@ -86,10 +90,12 @@ pixie.complete(target) # cleanup once installed
86
90
  `Pixie(...)`. Each hook `f(event, netboot, value, kwargs) -> value` is called for
87
91
  every `PixieEvent` and may transform the value flowing through it — used to
88
92
  customise lookup, context construction and the init/complete lifecycle.
89
- - **DHCP backends.** Subclass `netboot.dhcp.DhcpServer`; the lowercased class name
90
- is the URI scheme it handles (`class dnsmasq(DhcpServer)` → `dnsmasq://...`).
91
- Import your plugin module via `--load-module` so it is registered before the
92
- config builds the zones.
93
+ - **DHCP backends.** netboot ships four — `dnsmasq://`, `kea://`, `dhcpd://` and
94
+ `windhcp://` (Windows, over ssh or WinRM) — each imported only when a config
95
+ names its scheme. Write your own by subclassing `netboot.dhcp.DhcpServer`: the
96
+ lowercased class name is the URI scheme it handles, and `--load-module`
97
+ registers it before the config builds the zones. What the server should tell
98
+ the client rides in the URI query; see the configuration guide.
93
99
 
94
100
  A complete runnable setup — config, both template engines and a DHCP plugin —
95
101
  is in [`examples/`](https://github.com/jose-pr/netboot/tree/main/examples).
@@ -36,11 +36,9 @@ dhcpzones:
36
36
  nameservers: [10.0.0.53]
37
37
  search: [example.com]
38
38
  dhcpservers:
39
- # The scheme selects the DhcpServer backend. netboot ships none, so a
40
- # class named `dnsmasq` must be registered first -- see Extending -- and
41
- # `--load-module` must import it, or resolving this entry raises
42
- # ValueError for the unknown scheme.
43
- - dnsmasq://dhcp-host
39
+ # The scheme selects the backend; the query carries what the server
40
+ # should tell the client. See "DHCP servers and options" below.
41
+ - dnsmasq:///?hostsfile=/etc/dnsmasq.d/netboot.d/&router=10.0.0.1
44
42
 
45
43
  # Where boot artifacts are fetched / served from.
46
44
  repos:
@@ -51,6 +49,67 @@ repos:
51
49
  local: /srv/mirror/debian
52
50
  ```
53
51
 
52
+ ## DHCP servers and options
53
+
54
+ Each entry under a zone's `dhcpservers` is a URI. Its **scheme** picks the
55
+ backend; its **query string** carries that backend's connection settings and the
56
+ DHCP options the server should give the client.
57
+
58
+ | Scheme | Server | How it applies a reservation | Needs |
59
+ | ------ | ------ | ---------------------------- | ----- |
60
+ | `dnsmasq://` | dnsmasq | writes `dhcp-host`/`dhcp-option` files, local or over `sftp://` | nothing locally; `netboot[ssh]` for remote paths |
61
+ | `kea://`, `keas://` | ISC Kea | `reservation-add` / `reservation-del` via the control agent | `netboot[kea]` |
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 |
64
+
65
+ Each has one thing that is easy to get wrong:
66
+
67
+ - **dnsmasq** re-reads `--dhcp-hostsfile`/`--dhcp-optsfile` only on SIGHUP, and
68
+ never re-reads its main config — but `--dhcp-hostsdir`/`--dhcp-optsdir` pick up
69
+ changed files by themselves, which is why a directory needs no `reload=` and a
70
+ plain file does.
71
+ - **Kea** needs the `host_cmds` hook loaded *and* a writable hosts backend; a
72
+ file-only Kea loads the hook and still refuses.
73
+ - **dhcpd** hosts added over OMAPI do not survive a restart by themselves — that
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.
77
+
78
+ ### Options
79
+
80
+ Anything in the query that is not a connection setting is a client option:
81
+ `router`, `domain-name-servers`, `domain-name`, `domain-search`, `ntp-servers`,
82
+ `subnet-mask`, `broadcast-address`, `lease-time`, `vendor-class-identifier`, or
83
+ `option-<n>` for anything netboot does not model. Repeat a key for a list.
84
+ `raw.<backend>=` passes backend-native text through untranslated.
85
+
86
+ Options are merged in this order, later winning:
87
+
88
+ 1. what the zone already knows (`gateway` → `router`, `nameservers`, `domain`…);
89
+ 2. the server URI's query;
90
+ 3. `images.<id>.dhcp_options`;
91
+ 4. `targets.<id>.dhcp_options`;
92
+ 5. an `options_builder=my.module.function` — `fn(ctx, options) -> options`;
93
+ 6. any hook on `PixieEvent.BuildDhcpOptions`.
94
+
95
+ **Some things belong to the target, not the connection**, and netboot refuses
96
+ them in a URI with a message saying where they go: `subnet_id` and `scope` on the
97
+ **zone**, `boot-file-name`, `next-server`, `tftp-server-name` and `host-name` on
98
+ the **image** or **target**. A boot file pinned to a connection would hand every
99
+ target on that server the same one.
100
+
101
+ ```yaml
102
+ dhcpzones:
103
+ lan:
104
+ network: 10.0.0.0/24
105
+ subnet_id: 44 # kea; windhcp uses `scope`
106
+ dhcpservers:
107
+ - kea://10.0.0.1:8000/?domain-name-servers=10.0.0.53
108
+ images:
109
+ debian:
110
+ dhcp_options: {boot-file-name: pxelinux.0, next-server: 10.0.0.2}
111
+ ```
112
+
54
113
  ## Where templates are looked for
55
114
 
56
115
  `templates` is the list of **template roots** — the CWD's `templates` directory
@@ -62,6 +62,13 @@ class dnsmasq(DhcpServer): # handles dnsmasq://...
62
62
  ... # disarm it
63
63
  ```
64
64
 
65
+ netboot ships four backends — `netboot.dhcp.dnsmasq`, `.kea`, `.dhcpd` and
66
+ `.windhcp` — and they are the worked examples: one writes files (locally or over
67
+ `sftp://`), one speaks a REST API, one a binary protocol, and one runs
68
+ PowerShell over ssh or WinRM. A backend gets its client options from
69
+ `self.options_for(ctx)` and translates them; see the configuration guide for
70
+ what an operator writes.
71
+
65
72
  Subclassing at any depth is honoured, so a backend may share an intermediate
66
73
  base. Import your plugin module before the config builds the zones — pass
67
74
  `--load-module your.plugin` (repeat or colon-separate for several) so the
@@ -30,6 +30,10 @@ pip install .
30
30
  | `netboot[config]` | YAML config loading for the CLI (`pyyaml`) |
31
31
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
32
32
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
33
+ | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
34
+ | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
35
+ | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
36
+ | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
33
37
  | `netboot[docs]` | Build this documentation site (`mkdocs`) |
34
38
 
35
39
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
@@ -48,6 +48,11 @@ Expected output for `initiate web01`: the backend line, then the rendered
48
48
  renders it: `%{UPPER_SNAKE}` placeholders from the flattened context, and a
49
49
  bare `%` left alone (which is what lets a kickstart's `%packages` through).
50
50
  `install.ks.j2` is Jinja, with `ctx`, `shell_quote`, `Path` and `Uri` in scope.
51
+ - **A real DHCP backend.** `config/pixie.yaml` carries a commented
52
+ `dnsmasq://` entry beside the recording stub: uncomment it (and drop the stub)
53
+ to write real reservation files. The image's `dhcp_options` — the boot file and
54
+ its server — reach whichever backend you use, because they belong to the image
55
+ rather than to the connection.
51
56
  - **Repo URLs.** `ctx.repos["mirror"].service("tftp")` builds
52
57
  `tftp://10.0.0.2/debian` from the repo's address and service path. An
53
58
  `http`/`https` service would additionally need `pip install netboot[http]`.
@@ -17,6 +17,11 @@ images:
17
17
  template_path: [debian] # relative: searched inside the template root
18
18
  globals:
19
19
  kernel: vmlinuz
20
+ # What this image tells DHCP clients to boot. Merged into the options every
21
+ # backend sends; see docs/configuration.md.
22
+ dhcp_options:
23
+ boot-file-name: pxelinux.0
24
+ next-server: 10.0.0.2
20
25
 
21
26
  dhcpzones:
22
27
  lan:
@@ -26,8 +31,13 @@ dhcpzones:
26
31
  nameservers: [10.0.0.53]
27
32
  search: [example.com]
28
33
  dhcpservers:
29
- # Handled by examples/plugins/recording.py, imported with --load-module.
34
+ # Handled by examples/plugins/recording.py, imported with --load-module,
35
+ # so this example runs with no DHCP server at all.
30
36
  - recording://dhcp-host
37
+ # A real one looks like this -- swap it in when you have a dnsmasq. The
38
+ # query carries the options the server hands the client; the boot file
39
+ # comes from the image below, because it varies per target.
40
+ # - dnsmasq:///?hostsfile=/etc/dnsmasq.d/netboot.d/&router=10.0.0.1
31
41
 
32
42
  repos:
33
43
  mirror:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "netboot"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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"
@@ -30,10 +30,12 @@ classifiers = [
30
30
  ]
31
31
  dependencies = [
32
32
  "duho>=0.5.0,<0.6",
33
- # Above the .0 deliberately: netboot calls netimps' resolve() with no
34
- # rdtype, and 0.2.1 is the release that auto-selects it ("ptr" for an
35
- # address literal, "a" otherwise). Don't normalize this back to 0.2.0.
36
- "netimps>=0.2.1,<0.3",
33
+ # Above the .0 deliberately: 0.3.0 made resolve() raise ResolutionError
34
+ # when no backend could even ask (a resolver outage), and 0.3.1 restored
35
+ # [] as the default (strict=False). PixieTarget and Host.try_ip rely on
36
+ # that: every target is built at startup, so a raise would fail every
37
+ # command. Don't normalize this back to 0.3.0.
38
+ "netimps>=0.3.1,<0.4",
37
39
  # Above the .0 deliberately, measured 2026-09-17: recursive `!include`
38
40
  # globs (`sub/**/*.yaml`) break on pathlib_next >=0.9.5 unless yaconfiglib
39
41
  # is >=0.12.0, and yaconfiglib 0.12.0 itself requires pathlib-next>=0.9.9.
@@ -44,9 +46,7 @@ dependencies = [
44
46
  # markupsafe.soft_unicode, removed in MarkupSafe 2.1, so an unpinned
45
47
  # resolve can install a jinja2/markupsafe pair that fails on import.
46
48
  "jinja2>=3.0,<4",
47
- # Above a bare series base for the same measured reason: with the
48
- # pathlib-next this project uses, recursive config discovery needs 0.12.0.
49
- "yaconfiglib>=0.12.0,<0.13",
49
+ "yaconfiglib>=0.13.0,<0.14",
50
50
  ]
51
51
 
52
52
  [project.optional-dependencies]
@@ -57,13 +57,34 @@ config = ["pyyaml"]
57
57
  # pathlib_next[uri] does not. Repos served over file/tftp, and rendering in
58
58
  # general, need none of it, so this stays an extra rather than a dependency.
59
59
  http = ["requests"]
60
+ # DHCP backends. Each is imported only when a config names its scheme, so a
61
+ # netboot install carries none of these unless it asks for them. dnsmasq needs
62
+ # nothing for a local server, and windhcp nothing over its default ssh
63
+ # transport (it uses the system ssh client).
64
+ kea = ["requests"] # kea:// control agent
65
+ dhcpd = ["pypureomapi"] # dhcpd:// OMAPI
66
+ winrm = ["pywinrm"] # windhcp://...?transport=winrm
67
+ ssh = ["pathlib_next[sftp]"] # dnsmasq:// over sftp:// paths
60
68
  # The dnspython resolver backend for netimps' resolve(), used to look up
61
69
  # hostname targets. netimps >=0.2.0 makes dnspython optional, so this extra is
62
70
  # the only thing that installs it: with it, resolution has full record-type and
63
71
  # explicit-nameserver support; without it, resolve() still works through
64
72
  # netimps' system/nslookup backends (address and reverse records only).
65
73
  dns = ["dnspython"]
66
- dev = ["black", "build", "hatchling", "pytest", "pytest-cov", "twine", "pyyaml", "dnspython", "requests"]
74
+ dev = [
75
+ "black",
76
+ "build",
77
+ "hatchling",
78
+ "pytest",
79
+ "pytest-cov",
80
+ "twine",
81
+ "pyyaml",
82
+ "dnspython",
83
+ "requests",
84
+ # Backend dependencies, so their tests run rather than skip.
85
+ "pypureomapi",
86
+ "pywinrm",
87
+ ]
67
88
  docs = ["mkdocs", "mkdocs-material", "mkdocstrings[python]"]
68
89
 
69
90
  [project.scripts]
@@ -98,8 +98,8 @@ is the same object. `netboot.netutils` remains an alias for
98
98
  - **`PixieEvent`** (`StrEnum`) — hook event names: `NewPixieObject`,
99
99
  `StartPixieInit`, `SetPixieProperty`, `PixieInitiated`, `LookupTarget`,
100
100
  `FoundTarget`, `FoundTargetImage`, `FoundTargetDhcpzone`,
101
- `PixieContextForTarget`, `StartPixieInitialize`, `EndPixieInitialize`,
102
- `StartPixieComplete`, `EndPixieComplete`.
101
+ `PixieContextForTarget`, `BuildDhcpOptions`, `StartPixieInitialize`,
102
+ `EndPixieInitialize`, `StartPixieComplete`, `EndPixieComplete`.
103
103
 
104
104
  - **`PixieTarget(**kwargs)`** (`argparse.Namespace` + `yaconfiglib.OpaqueMerge`)
105
105
  — `_id`, `hostname`, `ip` (`IPAddress`), `mac` (`MACAddress`), `image`,
@@ -110,9 +110,10 @@ is the same object. `netboot.netutils` remains an alias for
110
110
  non-MAC `_id` fills `hostname`; then, if `ip` is still unset and a hostname
111
111
  is known, **exactly one** forward lookup
112
112
  (`netboot.utils.net.resolve`, no reverse lookup) fills it. A name that does
113
- not resolve leaves `ip` unset and logs a warning — it is never retried, and
114
- never raises. `hostname` is lower-cased. The `dns` extra is optional: without
115
- it netimps still resolves through its system/`nslookup` backends.
113
+ not resolve — including when no resolver is reachable at all — leaves `ip`
114
+ unset and logs a warning; it is never retried, and never raises. `hostname`
115
+ is lower-cased. The `dns` extra is optional: without it netimps still
116
+ resolves through its system/`nslookup` backends.
116
117
 
117
118
  - **`PixieImage(**kwargs)`** (`content.Resource`) — `template_path`,
118
119
  `globals`, plus `Resource`'s `src`/`path`, which address the image's
@@ -173,6 +174,12 @@ is the same object. `netboot.netutils` remains an alias for
173
174
  skips anything unparseable with a warning, and returns `default` when no
174
175
  server lies inside the zone's network.
175
176
 
177
+ - **`DhcpServer.SETTINGS`** — the query keys a backend reads as *connection*
178
+ settings. Every other key in a `dhcpservers` URI query is a **client option**
179
+ (see `netboot.dhcp.options`), so the two vocabularies must stay disjoint.
180
+ - **`DhcpServer.options_for(ctx) -> DhcpOptions`** — the merged client options
181
+ for this target on this server.
182
+
176
183
  - **`DhcpZone(**kwargs)`** (`Namespace` + `OpaqueMerge`) — `network`
177
184
  (`IPNetwork`), `gateway` (`IPAddress | None`), `domain` (`str | None`),
178
185
  `search` (`list[str]`), `nameservers` (`list[IPAddress]`), `globals`,
@@ -182,6 +189,64 @@ is the same object. `netboot.netutils` remains an alias for
182
189
  **`.get_local_server(servers, default)`** — first `server` contained in
183
190
  `.network`, else `default`.
184
191
 
192
+ ## DHCP backends (`netboot.dhcp.*`)
193
+
194
+ Four ship, each imported only when a config names its scheme — so importing
195
+ `netboot.dhcp` pulls in none of their dependencies. Every one takes its client
196
+ options from the URI query (see below) and translates them.
197
+
198
+ - **`dnsmasq://[host]/?hostsfile=&optsfile=&reload=&pidfile=`**
199
+ (`netboot.dhcp.dnsmasq`) — writes `dhcp-host`/`dhcp-option`/`dhcp-boot`
200
+ entries. Paths may carry their own scheme; a bare path is local when the URI
201
+ has no host and `sftp://<host>/<path>` when it does (`netboot[ssh]`). A
202
+ **directory** gets one file per target; a **file** gets a marked region netboot
203
+ owns. `reload=` runs a command, over ssh when the URI names a host — omit it
204
+ for a `--dhcp-hostsdir`, which dnsmasq re-reads by itself; a plain file
205
+ without it raises.
206
+ - **`kea://`, `keas://`** (`netboot.dhcp.kea`, `netboot[kea]`) —
207
+ `reservation-add`/`reservation-del` through the control agent. `?service=`
208
+ selects `dhcp4`/`dhcp6`. Needs the **host_cmds** hook and a writable hosts
209
+ backend; `result: 2` is reported with that cause. `subnet-id` comes from the
210
+ zone (`subnet_id`) or a matched `config-get`.
211
+ - **`dhcpd://host:7911/?keyname=&keyfile=`** (`netboot.dhcp.dhcpd`,
212
+ `netboot[dhcpd]`) — an OMAPI host object whose `statements` carry the options,
213
+ escaped. Secret from `keyfile=` or `$PIXIE_DHCPD_OMAPI_KEY`. A host added this
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**.
221
+
222
+ Each raises `PixieLookupError` for a target with no MAC — every one of these
223
+ servers identifies a reservation by it.
224
+
225
+ ## DHCP options (`netboot.dhcp.options`)
226
+
227
+ - **`GENERIC_OPTIONS`** — the option names netboot models and every backend
228
+ translates (`router`, `domain-name-servers`, `domain-name`, `domain-search`,
229
+ `next-server`, `boot-file-name`, `tftp-server-name`, `host-name`,
230
+ `subnet-mask`, `broadcast-address`, `lease-time`, `ntp-servers`,
231
+ `vendor-class-identifier`). A numeric `option-<n>` or a bare number is always
232
+ accepted, for anything not modelled.
233
+ - **`APPLY_TIME`** — names that are resolved **when a target is applied**, never
234
+ from a connection string: `subnet_id`/`scope` belong to the **zone**,
235
+ `boot-file-name`/`next-server`/`tftp-server-name` to the **image or target**,
236
+ `host-name` to the target itself. Passing one in a URI query raises
237
+ `PixieConfigError` naming where it belongs — a boot file pinned to a
238
+ connection would hand every target on that server the same one.
239
+ - **`DhcpOptions(dict)`** — the merged options, plus **`.raw`** /
240
+ **`.raw_for(backend)`**: backend-native text from `raw.<backend>=` in the
241
+ query, never translated.
242
+ - **`build_options(ctx, server) -> DhcpOptions`** — merges, later winning: zone
243
+ defaults (from `gateway`/`nameservers`/`domain`/`search`/`network`) → the
244
+ server URI's query → `image.dhcp_options` → `target.dhcp_options` → the
245
+ server's `options_builder=` callback (`fn(ctx, options) -> options`) → the
246
+ `PixieEvent.BuildDhcpOptions` hook. An unknown option name raises.
247
+ - **`split_query(uri, settings, backend)`** → `(settings, options, builder)`.
248
+ A repeated key becomes a list, which is how a multi-valued option is written.
249
+
185
250
  ## Content (`netboot.content`)
186
251
 
187
252
  - **`Resource(**kwargs)`** — `path` (`Pathname`, coerced via `_parse_path`),
@@ -273,7 +338,8 @@ is the same object. `netboot.netutils` remains an alias for
273
338
  - **`Host(address: str | Host | None = None)`** — a hostname-or-IP repo
274
339
  address. **`.try_ip() -> IPAddress | str`** — resolves to an `IPAddress`
275
340
  (an IP literal as-is, a hostname through one forward lookup); falls back to
276
- the original string on resolution failure. The `dns` extra is optional —
341
+ the original string on resolution failure, an unreachable resolver
342
+ included. The `dns` extra is optional —
277
343
  without it netimps resolves through its system/`nslookup` backends.
278
344
  Equality/hash by `.address`.
279
345
  - **`flatten(map, _prefix="") -> dict`** — recursively flattens a
@@ -304,13 +370,18 @@ is the same object. `netboot.netutils` remains an alias for
304
370
  - `parse(value, type, **kw)` raises on bad input; `try_parse(value, type)`
305
371
  returns `None` instead; `is_valid(value, type)` returns a bool. Networks
306
372
  parse non-strict, so `"10.0.0.5/24"` normalises rather than raising.
307
- - `MACAddress` — colon/hyphen/Cisco-dot/bare text, `int` or `bytes`;
308
- `.as_str(sep)`, `.packed`, `.oui`, hashable and ordered.
373
+ - `MACAddress` — colon/hyphen/per-octet-dot/Cisco-dot/bare text (one
374
+ separator per address), `int` or `bytes`; `.as_str(sep)`, `.packed`,
375
+ `.oui`, hashable and ordered. It equals only another `MACAddress`, never a
376
+ `str` — compare text with `MACAddress.try_parse(text) == mac`.
309
377
  - `resolve(query, rdtype=None)` → a list of **native** records (`A`/`AAAA`
310
378
  are `IPv4Address`/`IPv6Address`, not strings); `[]` on a genuine lookup
311
379
  failure, but a malformed query or unknown record type raises `ValueError`
312
380
  rather than looking like "no such record". `rdtype=None` auto-selects:
313
- `"ptr"` when `query` is an address literal, `"a"` otherwise.
381
+ `"ptr"` when `query` is an address literal, `"a"` otherwise. Backends are
382
+ tried until one answers non-empty, so hosts-file and NSS-only names
383
+ resolve too. An unreachable resolver also answers `[]`; netimps'
384
+ `strict=True` raises `netimps.ResolutionError` instead.
314
385
  - `ping(dst)` → a `PingResult` that is truthy on success and also carries
315
386
  `.rtt_ms`/`.ttl`.
316
387
  - `get_interfaces()` / `iter_addresses()` — real adapter enumeration with
@@ -14,6 +14,20 @@ from ..utils.net import Host
14
14
  _HTTP_SCHEMES = ("http", "https")
15
15
 
16
16
 
17
+ def _split_port(text: str) -> "tuple[str, _ty.Optional[int]]":
18
+ """Split `host:port`, `[v6]:port` or a bare host into its two parts."""
19
+ if not text:
20
+ return "", None
21
+ if text.startswith("["): # [2001:db8::1]:8080
22
+ literal, _, rest = text.partition("]")
23
+ port = rest.lstrip(":")
24
+ return literal + "]", int(port) if port.isdigit() else None
25
+ head, sep, tail = text.rpartition(":")
26
+ if sep and tail.isdigit() and head and ":" not in head:
27
+ return head, int(tail)
28
+ return text, None
29
+
30
+
17
31
  def _scheme_of(value: object) -> str:
18
32
  """The scheme of a configured service URI, or ``""`` for a bare path."""
19
33
  text = str(value)
@@ -124,18 +138,18 @@ class Repository(_NS):
124
138
  def _service_host(self, scheme: str) -> "tuple[str, _ty.Optional[int]]":
125
139
  """The authority for a relative service path: host and optional port.
126
140
 
127
- TLS is the exception to resolving: a certificate is issued for a name,
128
- and a name-based virtual host needs one too, so `https` keeps the
141
+ The port is split off **before** anything is resolved: an address like
142
+ `mirror.example:8080` is a host and a port, and asking DNS for the whole
143
+ string is a lookup that can only fail (slowly, and differently on every
144
+ machine).
145
+
146
+ TLS is the exception to resolving at all: a certificate is issued for a
147
+ name, and a name-based virtual host needs one too, so `https` keeps the
129
148
  address as configured. Other schemes use the resolved IP, because a PXE
130
149
  client often has no working DNS when it fetches its boot files.
131
150
  """
132
- configured = str(self.address)
133
- text = (
134
- configured
135
- if scheme == "https" and configured
136
- else str(self.address.try_ip())
137
- )
138
- if not text:
151
+ host, port = _split_port(str(self.address))
152
+ if not host:
139
153
  LOGGER.warning(
140
154
  "repository has no address, so %s:// URLs are built without a "
141
155
  "host; give the repo an `address`, or write the service as a "
@@ -143,16 +157,9 @@ class Repository(_NS):
143
157
  scheme,
144
158
  )
145
159
  return "", None
146
- if text.startswith("["): # [2001:db8::1]:8080
147
- literal, _, rest = text.partition("]")
148
- port = rest.lstrip(":")
149
- return literal + "]", int(port) if port.isdigit() else None
150
- head, sep, tail = text.rpartition(":")
151
- if sep and tail.isdigit() and head and ":" not in head:
152
- # "mirror.example:8080" is a host and a port, not a host whose name
153
- # contains a colon (which percent-encoded into `%3A`).
154
- return head, int(tail)
155
- return text, None
160
+ if scheme == "https":
161
+ return host, port
162
+ return str(Host(host).try_ip()), port
156
163
 
157
164
  def service(self, name: str):
158
165
  """The base URI for a service name, `None` for `.local` when unset.