netboot 0.2.0__tar.gz → 0.2.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 (67) hide show
  1. {netboot-0.2.0 → netboot-0.2.1}/CHANGELOG.md +75 -1
  2. {netboot-0.2.0 → netboot-0.2.1}/PKG-INFO +21 -5
  3. {netboot-0.2.0 → netboot-0.2.1}/README.md +10 -4
  4. {netboot-0.2.0 → netboot-0.2.1}/docs/configuration.md +64 -5
  5. {netboot-0.2.0 → netboot-0.2.1}/docs/extending.md +7 -0
  6. {netboot-0.2.0 → netboot-0.2.1}/docs/index.md +4 -0
  7. {netboot-0.2.0 → netboot-0.2.1}/examples/README.md +5 -0
  8. {netboot-0.2.0 → netboot-0.2.1}/examples/config/pixie.yaml +11 -1
  9. {netboot-0.2.0 → netboot-0.2.1}/pyproject.toml +23 -2
  10. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/AGENTS.md +66 -2
  11. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/content/__init__.py +26 -19
  12. netboot-0.2.0/src/netboot/dhcp.py → netboot-0.2.1/src/netboot/dhcp/__init__.py +57 -5
  13. netboot-0.2.1/src/netboot/dhcp/dhcpd.py +204 -0
  14. netboot-0.2.1/src/netboot/dhcp/dnsmasq.py +311 -0
  15. netboot-0.2.1/src/netboot/dhcp/kea.py +209 -0
  16. netboot-0.2.1/src/netboot/dhcp/options.py +226 -0
  17. netboot-0.2.1/src/netboot/dhcp/windhcp.py +249 -0
  18. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/engine.py +4 -0
  19. {netboot-0.2.0 → netboot-0.2.1}/tests/test_content.py +7 -3
  20. netboot-0.2.1/tests/test_dhcp_dhcpd.py +211 -0
  21. netboot-0.2.1/tests/test_dhcp_dnsmasq.py +225 -0
  22. netboot-0.2.1/tests/test_dhcp_kea.py +236 -0
  23. netboot-0.2.1/tests/test_dhcp_options.py +270 -0
  24. netboot-0.2.1/tests/test_dhcp_windhcp.py +230 -0
  25. {netboot-0.2.0 → netboot-0.2.1}/.gitignore +0 -0
  26. {netboot-0.2.0 → netboot-0.2.1}/AGENTS.md +0 -0
  27. {netboot-0.2.0 → netboot-0.2.1}/LICENSE +0 -0
  28. {netboot-0.2.0 → netboot-0.2.1}/benchmarks/README.md +0 -0
  29. {netboot-0.2.0 → netboot-0.2.1}/benchmarks/bench_netboot.py +0 -0
  30. {netboot-0.2.0 → netboot-0.2.1}/benchmarks/results/netboot.json +0 -0
  31. {netboot-0.2.0 → netboot-0.2.1}/docs/api.md +0 -0
  32. {netboot-0.2.0 → netboot-0.2.1}/docs/changelog.md +0 -0
  33. {netboot-0.2.0 → netboot-0.2.1}/docs/cli.md +0 -0
  34. {netboot-0.2.0 → netboot-0.2.1}/examples/plugins/recording.py +0 -0
  35. {netboot-0.2.0 → netboot-0.2.1}/examples/templates/debian/boot.cfg +0 -0
  36. {netboot-0.2.0 → netboot-0.2.1}/examples/templates/debian/install.ks.j2 +0 -0
  37. {netboot-0.2.0 → netboot-0.2.1}/mkdocs.yml +0 -0
  38. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/__init__.py +0 -0
  39. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/__main__.py +0 -0
  40. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/_version.py +0 -0
  41. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/cmds/__init__.py +0 -0
  42. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/cmds/complete.py +0 -0
  43. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/cmds/initiate.py +0 -0
  44. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/logging.py +0 -0
  45. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/main.py +0 -0
  46. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/py.typed +0 -0
  47. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/templates/__init__.py +0 -0
  48. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/templates/common.py +0 -0
  49. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/templates/jinja.py +0 -0
  50. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/templates/shell.py +0 -0
  51. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/utils/__init__.py +0 -0
  52. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/utils/config.py +0 -0
  53. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/utils/dicts.py +0 -0
  54. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/utils/misc.py +0 -0
  55. {netboot-0.2.0 → netboot-0.2.1}/src/netboot/utils/net.py +0 -0
  56. {netboot-0.2.0 → netboot-0.2.1}/tests/conftest.py +0 -0
  57. {netboot-0.2.0 → netboot-0.2.1}/tests/test_api_contracts.py +0 -0
  58. {netboot-0.2.0 → netboot-0.2.1}/tests/test_cli.py +0 -0
  59. {netboot-0.2.0 → netboot-0.2.1}/tests/test_cli_discovery.py +0 -0
  60. {netboot-0.2.0 → netboot-0.2.1}/tests/test_config_discovery.py +0 -0
  61. {netboot-0.2.0 → netboot-0.2.1}/tests/test_dhcp.py +0 -0
  62. {netboot-0.2.0 → netboot-0.2.1}/tests/test_engine_robustness.py +0 -0
  63. {netboot-0.2.0 → netboot-0.2.1}/tests/test_hooks.py +0 -0
  64. {netboot-0.2.0 → netboot-0.2.1}/tests/test_lookup.py +0 -0
  65. {netboot-0.2.0 → netboot-0.2.1}/tests/test_render.py +0 -0
  66. {netboot-0.2.0 → netboot-0.2.1}/tests/test_target.py +0 -0
  67. {netboot-0.2.0 → netboot-0.2.1}/tests/test_utils.py +0 -0
@@ -6,6 +6,79 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-09-17
10
+
11
+ netboot ships DHCP backends. Additive: nothing documented changed behaviour,
12
+ and a config that named no `dhcpservers` scheme netboot implements works as
13
+ before.
14
+
15
+ ### Fixed
16
+ - A repository `address` of the form `host:port` no longer sends the whole
17
+ string to DNS. The port is split off first, so `mirror.example:8080`
18
+ resolves `mirror.example` — the old lookup could only fail, and did so at
19
+ different speeds on different machines.
20
+
21
+ ### Added
22
+ - `netboot.dhcp` is a package, and DHCP backends carry **client options**. A
23
+ `dhcpservers` entry is still a plain URI string; its query string now holds the
24
+ options the server should give the client (`?router=10.0.0.1`), alongside each
25
+ backend's own connection settings. `netboot.dhcp.options` models them:
26
+ `GENERIC_OPTIONS` for the names netboot translates, `option-<n>` for anything
27
+ else, `raw.<backend>=` for untranslated backend-native text, and
28
+ `options_builder=my.mod.fn` plus the new `PixieEvent.BuildDhcpOptions` for
29
+ building them from the context. Merge order, later winning: zone defaults, the
30
+ connection query, `image.dhcp_options`, `target.dhcp_options`, the callback,
31
+ the hook.
32
+ - **A dnsmasq backend** (`dnsmasq://`), the first netboot ships. It writes
33
+ `dhcp-host`/`dhcp-option`/`dhcp-boot` entries through `pathlib_next` paths, so
34
+ the same configuration serves a local dnsmasq or one reached over `sftp://`
35
+ (`netboot[ssh]`). `hostsfile`/`optsfile` may be a **directory** (one file per
36
+ target — preferred, and with `--dhcp-hostsdir`/`--dhcp-optsdir` dnsmasq re-reads
37
+ them with no signal at all) or a **file**, where netboot edits only its own
38
+ marked region and never touches a line it did not write. `reload=` runs a
39
+ command, over ssh when the URI names a host; omitting it is fine for a watched
40
+ directory and an error for a file, which dnsmasq never re-reads by itself.
41
+ - **A Kea backend** (`kea://`, `keas://` for https). It sends `reservation-add`
42
+ and `reservation-del` to the control agent, mapping `boot-file-name`,
43
+ `next-server` and `host-name` onto reservation *fields* and everything else
44
+ onto `option-data`. The `subnet-id` comes from the zone (`subnet_id:`) or is
45
+ discovered once by matching the zone's network against `config-get`; when
46
+ neither works netboot refuses rather than guessing, because a reservation in
47
+ the wrong subnet silently never matches. A `result: 2` from Kea is reported
48
+ with the likely cause (host_cmds not loaded, or a read-only hosts backend),
49
+ and an existing reservation for the same MAC is refused rather than
50
+ overwritten. Needs `netboot[kea]`.
51
+ - **An ISC dhcpd backend** (`dhcpd://`) over OMAPI, the only way to give dhcpd a
52
+ reservation without rewriting and reloading its config. Options are rendered
53
+ into the host's `statements`, which is dhcpd config *source*: text values are
54
+ quoted with `"` and `\` escaped, a value meant to be an address must look like
55
+ one, and a newline is refused outright — a value cannot end its statement and
56
+ start another. The OMAPI secret comes from `keyfile=` or
57
+ `$PIXIE_DHCPD_OMAPI_KEY`, never from the URI. Needs `netboot[dhcpd]`. Note
58
+ that a host added over OMAPI does not survive a dhcpd restart by itself: that
59
+ is dhcpd's design, not netboot forgetting it.
60
+ - **A Windows DHCP Server backend** (`windhcp://`), driving the `DhcpServer`
61
+ PowerShell module over **ssh** (the default, no dependency — it uses the system
62
+ `ssh` client, so your config, keys, agent and jump hosts apply) or **WinRM**
63
+ (`netboot[winrm]`). The transport host and the DHCP server are separate:
64
+ `server=` becomes `-ComputerName` when the cmdlets should act elsewhere. The
65
+ scope is the zone's network address, resolved when the target is applied and
66
+ overridable per zone. No value is interpolated into the script: parameters
67
+ travel as a JSON payload PowerShell parses, so a target name or option value
68
+ cannot become a statement.
69
+ - New extras, each carrying exactly one backend's dependency: `netboot[kea]`,
70
+ `netboot[dhcpd]`, `netboot[winrm]`, and `netboot[ssh]` for dnsmasq over
71
+ `sftp://`. A local dnsmasq and `windhcp://` over ssh need none. Verified on a
72
+ clean wheel install: importing `netboot.dhcp` loads no backend dependency,
73
+ those two backends construct, and the other two raise `ImportError` naming
74
+ their extra.
75
+ - Anything that varies per target is refused in a connection string and resolved
76
+ when the target is applied instead: `subnet_id`/`scope` belong to the zone,
77
+ `boot-file-name`/`next-server`/`tftp-server-name` to the image or target. The
78
+ error names where each belongs. `from netboot.dhcp import DhcpServer, DhcpZone`
79
+ is unchanged, and a backend module is imported only when a config names its
80
+ scheme.
81
+
9
82
  ## [0.2.0] - 2026-09-17
10
83
 
11
84
  A correctness and hardening release from a full review of the code base.
@@ -447,7 +520,8 @@ First packaged release: the `netboot` library with the `pixie` command line.
447
520
  config value construction no longer swallows non-`TypeError` errors; repo
448
521
  `joinpath` keeps `.local` a path so chained joins work.
449
522
 
450
- [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.0...HEAD
523
+ [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.1...HEAD
524
+ [0.2.1]: https://github.com/jose-pr/netboot/compare/v0.2.0...v0.2.1
451
525
  [0.2.0]: https://github.com/jose-pr/netboot/compare/v0.1.3...v0.2.0
452
526
  [0.1.3]: https://github.com/jose-pr/netboot/compare/v0.1.2...v0.1.3
453
527
  [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.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
@@ -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.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"
@@ -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`,
@@ -173,6 +173,12 @@ is the same object. `netboot.netutils` remains an alias for
173
173
  skips anything unparseable with a warning, and returns `default` when no
174
174
  server lies inside the zone's network.
175
175
 
176
+ - **`DhcpServer.SETTINGS`** — the query keys a backend reads as *connection*
177
+ settings. Every other key in a `dhcpservers` URI query is a **client option**
178
+ (see `netboot.dhcp.options`), so the two vocabularies must stay disjoint.
179
+ - **`DhcpServer.options_for(ctx) -> DhcpOptions`** — the merged client options
180
+ for this target on this server.
181
+
176
182
  - **`DhcpZone(**kwargs)`** (`Namespace` + `OpaqueMerge`) — `network`
177
183
  (`IPNetwork`), `gateway` (`IPAddress | None`), `domain` (`str | None`),
178
184
  `search` (`list[str]`), `nameservers` (`list[IPAddress]`), `globals`,
@@ -182,6 +188,64 @@ is the same object. `netboot.netutils` remains an alias for
182
188
  **`.get_local_server(servers, default)`** — first `server` contained in
183
189
  `.network`, else `default`.
184
190
 
191
+ ## DHCP backends (`netboot.dhcp.*`)
192
+
193
+ Four ship, each imported only when a config names its scheme — so importing
194
+ `netboot.dhcp` pulls in none of their dependencies. Every one takes its client
195
+ options from the URI query (see below) and translates them.
196
+
197
+ - **`dnsmasq://[host]/?hostsfile=&optsfile=&reload=&pidfile=`**
198
+ (`netboot.dhcp.dnsmasq`) — writes `dhcp-host`/`dhcp-option`/`dhcp-boot`
199
+ entries. Paths may carry their own scheme; a bare path is local when the URI
200
+ has no host and `sftp://<host>/<path>` when it does (`netboot[ssh]`). A
201
+ **directory** gets one file per target; a **file** gets a marked region netboot
202
+ owns. `reload=` runs a command, over ssh when the URI names a host — omit it
203
+ for a `--dhcp-hostsdir`, which dnsmasq re-reads by itself; a plain file
204
+ without it raises.
205
+ - **`kea://`, `keas://`** (`netboot.dhcp.kea`, `netboot[kea]`) —
206
+ `reservation-add`/`reservation-del` through the control agent. `?service=`
207
+ selects `dhcp4`/`dhcp6`. Needs the **host_cmds** hook and a writable hosts
208
+ backend; `result: 2` is reported with that cause. `subnet-id` comes from the
209
+ zone (`subnet_id`) or a matched `config-get`.
210
+ - **`dhcpd://host:7911/?keyname=&keyfile=`** (`netboot.dhcp.dhcpd`,
211
+ `netboot[dhcpd]`) — an OMAPI host object whose `statements` carry the options,
212
+ escaped. Secret from `keyfile=` or `$PIXIE_DHCPD_OMAPI_KEY`. A host added this
213
+ way does **not** survive a dhcpd restart by itself.
214
+ - **`windhcp://[user@]host/?transport=ssh|winrm&server=&auth=&port=&ssl=`**
215
+ (`netboot.dhcp.windhcp`) — the `DhcpServer` PowerShell cmdlets over the system
216
+ `ssh` client (default, no dependency) or WinRM (`netboot[winrm]`, password
217
+ from `$PIXIE_WINDHCP_PASSWORD`). `server=` becomes `-ComputerName`. Parameters
218
+ travel as a JSON payload, never interpolated into the script. The scope is the
219
+ zone's network address, overridable with `scope` **on the zone**.
220
+
221
+ Each raises `PixieLookupError` for a target with no MAC — every one of these
222
+ servers identifies a reservation by it.
223
+
224
+ ## DHCP options (`netboot.dhcp.options`)
225
+
226
+ - **`GENERIC_OPTIONS`** — the option names netboot models and every backend
227
+ translates (`router`, `domain-name-servers`, `domain-name`, `domain-search`,
228
+ `next-server`, `boot-file-name`, `tftp-server-name`, `host-name`,
229
+ `subnet-mask`, `broadcast-address`, `lease-time`, `ntp-servers`,
230
+ `vendor-class-identifier`). A numeric `option-<n>` or a bare number is always
231
+ accepted, for anything not modelled.
232
+ - **`APPLY_TIME`** — names that are resolved **when a target is applied**, never
233
+ from a connection string: `subnet_id`/`scope` belong to the **zone**,
234
+ `boot-file-name`/`next-server`/`tftp-server-name` to the **image or target**,
235
+ `host-name` to the target itself. Passing one in a URI query raises
236
+ `PixieConfigError` naming where it belongs — a boot file pinned to a
237
+ connection would hand every target on that server the same one.
238
+ - **`DhcpOptions(dict)`** — the merged options, plus **`.raw`** /
239
+ **`.raw_for(backend)`**: backend-native text from `raw.<backend>=` in the
240
+ query, never translated.
241
+ - **`build_options(ctx, server) -> DhcpOptions`** — merges, later winning: zone
242
+ defaults (from `gateway`/`nameservers`/`domain`/`search`/`network`) → the
243
+ server URI's query → `image.dhcp_options` → `target.dhcp_options` → the
244
+ server's `options_builder=` callback (`fn(ctx, options) -> options`) → the
245
+ `PixieEvent.BuildDhcpOptions` hook. An unknown option name raises.
246
+ - **`split_query(uri, settings, backend)`** → `(settings, options, builder)`.
247
+ A repeated key becomes a list, which is how a multi-valued option is written.
248
+
185
249
  ## Content (`netboot.content`)
186
250
 
187
251
  - **`Resource(**kwargs)`** — `path` (`Pathname`, coerced via `_parse_path`),
@@ -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.
@@ -1,17 +1,51 @@
1
+ import importlib as _importlib
1
2
  import typing as _ty
2
3
  from argparse import Namespace
3
4
 
4
- from .utils import net as netutils
5
+ from ..utils import net as netutils
5
6
  from yaconfiglib import OpaqueMerge
6
7
 
7
- from .logging import LOGGER
8
- from .utils.net import IPAddress, IPInterface, IPNetwork
8
+ from ..logging import LOGGER
9
+ from ..utils.net import IPAddress, IPInterface, IPNetwork
9
10
 
10
11
  if _ty.TYPE_CHECKING:
11
- from . import PixieContext
12
+ from .. import PixieContext
12
13
 
13
14
  from urllib.parse import urlparse
14
15
 
16
+ from .options import DhcpOptions, build_options, split_query
17
+
18
+ #: Backends netboot ships, and the extra each needs. Used only to turn "no
19
+ #: backend for scheme" into a message that names the fix.
20
+ _SHIPPED = {
21
+ "dnsmasq": "netboot[ssh] for remote paths (local needs nothing)",
22
+ "kea": "netboot[kea]",
23
+ "dhcpd": "netboot[dhcpd]",
24
+ "windhcp": "netboot[winrm] for WinRM (ssh needs nothing)",
25
+ }
26
+
27
+ #: Scheme -> the module that defines it, when they differ (`keas://` is the
28
+ #: same backend as `kea://`, over https).
29
+ _ALIASES = {"keas": "kea"}
30
+
31
+
32
+ def _load_backend(scheme: "str|None") -> None:
33
+ """Import the shipped backend for `scheme`, if there is one.
34
+
35
+ Keeps `import netboot.dhcp` free of requests/paramiko/pywinrm: a backend and
36
+ its dependency are only imported when a config actually names its scheme. A
37
+ missing *backend module* is not an error (a plugin may provide the scheme),
38
+ but a backend that fails to import for its own reasons must say so.
39
+ """
40
+ if not scheme or not scheme.isidentifier():
41
+ return
42
+ module = _ALIASES.get(scheme, scheme)
43
+ try:
44
+ _importlib.import_module(f"{__name__}.{module}")
45
+ except ModuleNotFoundError as exc:
46
+ if exc.name != f"{__name__}.{module}":
47
+ raise # the backend imported, one of *its* imports is missing
48
+
15
49
 
16
50
  def _iter_subclasses(cls: type) -> "_ty.Iterator[type]":
17
51
  """Yield every subclass of ``cls``, recursively (not just direct children).
@@ -35,10 +69,15 @@ class DhcpServer:
35
69
 
36
70
  DEFAULT_SCHEME = None
37
71
 
72
+ #: Query keys this backend reads as connection settings. Everything else in
73
+ #: the query is a client option -- the two sets must stay disjoint.
74
+ SETTINGS: "frozenset[str]" = frozenset()
75
+
38
76
  def __new__(cls, uri: str):
39
77
  if cls is DhcpServer:
40
78
  parsed = urlparse(uri)
41
79
  scheme = parsed.scheme or cls.DEFAULT_SCHEME
80
+ _load_backend(scheme)
42
81
  handlers = [
43
82
  sub for sub in _iter_subclasses(cls) if sub.__name__.lower() == scheme
44
83
  ]
@@ -54,14 +93,27 @@ class DhcpServer:
54
93
  )
55
94
  if handlers:
56
95
  return object.__new__(handlers[-1])
96
+ hint = _SHIPPED.get(scheme)
57
97
  raise ValueError(
58
98
  f"No DhcpServer backend registered for scheme {scheme!r} "
59
- f"(uri={uri!r}); import a plugin module providing it via --load-module"
99
+ f"(uri={uri!r}); "
100
+ + (
101
+ f"install {hint}"
102
+ if hint
103
+ else "import a plugin module providing it via --load-module"
104
+ )
60
105
  )
61
106
  return object.__new__(cls)
62
107
 
63
108
  def __init__(self, uri: str):
64
109
  self.uri = uri
110
+ self.settings, self.options, self.options_builder = split_query(
111
+ uri, self.SETTINGS, type(self).__name__
112
+ )
113
+
114
+ def options_for(self, ctx) -> DhcpOptions:
115
+ """The client options for this target on this server, fully merged."""
116
+ return build_options(ctx, self)
65
117
 
66
118
  def remove_target(self, netboot: "PixieContext"):
67
119
  """Disarm this backend for the target in `netboot` (a `PixieContext`)."""