netboot 0.2.4__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 (88) hide show
  1. {netboot-0.2.4 → netboot-0.3.1}/AGENTS.md +9 -1
  2. {netboot-0.2.4 → netboot-0.3.1}/CHANGELOG.md +112 -2
  3. {netboot-0.2.4 → netboot-0.3.1}/PKG-INFO +26 -2
  4. {netboot-0.2.4 → netboot-0.3.1}/README.md +13 -1
  5. {netboot-0.2.4 → netboot-0.3.1}/docs/api.md +14 -0
  6. {netboot-0.2.4 → netboot-0.3.1}/docs/configuration.md +113 -7
  7. netboot-0.3.1/docs/extending.md +215 -0
  8. {netboot-0.2.4 → netboot-0.3.1}/examples/README.md +7 -5
  9. netboot-0.3.1/examples/templates/debian/boot.cfg.shtpl +8 -0
  10. {netboot-0.2.4 → netboot-0.3.1}/pyproject.toml +15 -1
  11. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/AGENTS.md +141 -21
  12. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/dhcp/__init__.py +40 -1
  13. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/dhcp/dhcpd.py +1 -1
  14. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/dhcp/dnsmasq.py +10 -3
  15. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/dhcp/kea.py +1 -1
  16. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/dhcp/options.py +26 -5
  17. netboot-0.3.1/src/netboot/dhcp/windhcp.py +517 -0
  18. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/engine.py +5 -3
  19. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/templates/__init__.py +111 -10
  20. netboot-0.3.1/src/netboot/templates/common.py +171 -0
  21. netboot-0.3.1/src/netboot/templates/copy.py +76 -0
  22. netboot-0.3.1/src/netboot/templates/data.py +160 -0
  23. netboot-0.3.1/src/netboot/templates/external.py +220 -0
  24. netboot-0.3.1/src/netboot/templates/handlebars.py +54 -0
  25. netboot-0.3.1/src/netboot/templates/jinja.py +39 -0
  26. netboot-0.3.1/src/netboot/templates/liquid.py +59 -0
  27. netboot-0.3.1/src/netboot/templates/mako.py +131 -0
  28. netboot-0.3.1/src/netboot/templates/mustache.py +55 -0
  29. netboot-0.3.1/src/netboot/templates/shell.py +161 -0
  30. netboot-0.3.1/tests/test_dhcp_extras.py +243 -0
  31. netboot-0.3.1/tests/test_dhcp_windhcp.py +447 -0
  32. netboot-0.3.1/tests/test_dhcp_windhcp_powershell.py +331 -0
  33. netboot-0.3.1/tests/test_external_engines.py +244 -0
  34. netboot-0.3.1/tests/test_mako.py +133 -0
  35. netboot-0.3.1/tests/test_optional_engines.py +206 -0
  36. {netboot-0.2.4 → netboot-0.3.1}/tests/test_render.py +22 -22
  37. netboot-0.3.1/tests/test_shell_template.py +257 -0
  38. netboot-0.3.1/tests/test_template_registry.py +150 -0
  39. netboot-0.2.4/docs/extending.md +0 -77
  40. netboot-0.2.4/examples/templates/debian/boot.cfg +0 -6
  41. netboot-0.2.4/src/netboot/dhcp/windhcp.py +0 -249
  42. netboot-0.2.4/src/netboot/templates/common.py +0 -50
  43. netboot-0.2.4/src/netboot/templates/jinja.py +0 -24
  44. netboot-0.2.4/src/netboot/templates/shell.py +0 -85
  45. netboot-0.2.4/tests/test_dhcp_windhcp.py +0 -230
  46. {netboot-0.2.4 → netboot-0.3.1}/.gitignore +0 -0
  47. {netboot-0.2.4 → netboot-0.3.1}/LICENSE +0 -0
  48. {netboot-0.2.4 → netboot-0.3.1}/benchmarks/README.md +0 -0
  49. {netboot-0.2.4 → netboot-0.3.1}/benchmarks/bench_netboot.py +0 -0
  50. {netboot-0.2.4 → netboot-0.3.1}/benchmarks/results/netboot.json +0 -0
  51. {netboot-0.2.4 → netboot-0.3.1}/docs/changelog.md +0 -0
  52. {netboot-0.2.4 → netboot-0.3.1}/docs/cli.md +0 -0
  53. {netboot-0.2.4 → netboot-0.3.1}/docs/index.md +0 -0
  54. {netboot-0.2.4 → netboot-0.3.1}/examples/config/pixie.yaml +0 -0
  55. {netboot-0.2.4 → netboot-0.3.1}/examples/plugins/recording.py +0 -0
  56. {netboot-0.2.4 → netboot-0.3.1}/examples/templates/debian/install.ks.j2 +0 -0
  57. {netboot-0.2.4 → netboot-0.3.1}/mkdocs.yml +0 -0
  58. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/__init__.py +0 -0
  59. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/__main__.py +0 -0
  60. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/_version.py +0 -0
  61. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/cmds/__init__.py +0 -0
  62. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/cmds/complete.py +0 -0
  63. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/cmds/initiate.py +0 -0
  64. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/content/__init__.py +0 -0
  65. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/logging.py +0 -0
  66. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/main.py +0 -0
  67. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/py.typed +0 -0
  68. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/utils/__init__.py +0 -0
  69. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/utils/config.py +0 -0
  70. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/utils/dicts.py +0 -0
  71. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/utils/misc.py +0 -0
  72. {netboot-0.2.4 → netboot-0.3.1}/src/netboot/utils/net.py +0 -0
  73. {netboot-0.2.4 → netboot-0.3.1}/tests/conftest.py +0 -0
  74. {netboot-0.2.4 → netboot-0.3.1}/tests/test_api_contracts.py +0 -0
  75. {netboot-0.2.4 → netboot-0.3.1}/tests/test_cli.py +0 -0
  76. {netboot-0.2.4 → netboot-0.3.1}/tests/test_cli_discovery.py +0 -0
  77. {netboot-0.2.4 → netboot-0.3.1}/tests/test_config_discovery.py +0 -0
  78. {netboot-0.2.4 → netboot-0.3.1}/tests/test_content.py +0 -0
  79. {netboot-0.2.4 → netboot-0.3.1}/tests/test_dhcp.py +0 -0
  80. {netboot-0.2.4 → netboot-0.3.1}/tests/test_dhcp_dhcpd.py +0 -0
  81. {netboot-0.2.4 → netboot-0.3.1}/tests/test_dhcp_dnsmasq.py +0 -0
  82. {netboot-0.2.4 → netboot-0.3.1}/tests/test_dhcp_kea.py +0 -0
  83. {netboot-0.2.4 → netboot-0.3.1}/tests/test_dhcp_options.py +0 -0
  84. {netboot-0.2.4 → netboot-0.3.1}/tests/test_engine_robustness.py +0 -0
  85. {netboot-0.2.4 → netboot-0.3.1}/tests/test_hooks.py +0 -0
  86. {netboot-0.2.4 → netboot-0.3.1}/tests/test_lookup.py +0 -0
  87. {netboot-0.2.4 → netboot-0.3.1}/tests/test_target.py +0 -0
  88. {netboot-0.2.4 → netboot-0.3.1}/tests/test_utils.py +0 -0
@@ -36,7 +36,15 @@ python -m venv .venv/3.14-nt-amd64
36
36
  ```
37
37
 
38
38
  Install **every extra that has tests**: without `http` the repository-service
39
- tests skip rather than run, and `pytest -rs` is how you see that happen.
39
+ tests skip rather than run, and `pytest -rs` is how you see that happen. The
40
+ `dev` extra pulls in the optional template engines' libraries (`mako`,
41
+ `python-liquid`, `pybars3`, `chevron`) for the same reason.
42
+
43
+ `tests/test_external_engines.py` needs **`ruby`** and **`puppet`**, which this
44
+ Windows box does not have: those cases skip here and run under WSL
45
+ (`FedoraLinux-44` has both). A change to `.erb`/`.epp` rendering is unverified
46
+ until the suite has run there — the engine-shape cases (registration, suffixes,
47
+ the missing-program error) do run everywhere.
40
48
 
41
49
  ## Commands
42
50
 
@@ -4,7 +4,115 @@ 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
- ## [Unreleased]
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
+
47
+ ## [0.3.0] - 2026-10-02
48
+
49
+ ### Changed
50
+ - **The shell template engine now claims the `.shtpl` suffix only** (it claimed
51
+ *every* suffix before, as the documented fallback). The suffix picks the engine
52
+ and the stem names the artifact, which is the convention `install.ks.j2`
53
+ already used: rename `boot.cfg` to `boot.cfg.shtpl` and `ctx.render("boot.cfg")`
54
+ keeps working unchanged. A file no engine claims is **copied as is** by the new
55
+ `CopyTemplate`, last in the default `template_types` — so a static `grub.cfg`
56
+ needs no engine, and a file that *does* carry `%{NAME}` placeholders ships them
57
+ unrendered until it is renamed. That one case logs a warning naming the rename.
58
+ Narrowing `template_types` so nothing claims a file raises
59
+ `netboot.templates.TemplateEngineError`, which names the file and every
60
+ engine's suffixes.
61
+
62
+ ### Added
63
+ - **Seven more template engines**, each claiming its own suffix and costing
64
+ nothing when unused: `MakoTemplate` (`.mako`, `netboot[mako]`),
65
+ `LiquidTemplate` (`.liquid`, `netboot[liquid]`), `HandlebarsTemplate`
66
+ (`.hbs`/`.handlebars`, `netboot[handlebars]`), `MustacheTemplate` (`.mustache`,
67
+ `netboot[mustache]`), and `ERBTemplate` (`.erb`) / `EppTemplate` (`.epp`),
68
+ which render through the system `ruby` and `puppet epp render` so an existing
69
+ template behaves as its author tested it (`PIXIE_RUBY` / `PIXIE_PUPPET` name
70
+ the program if it is not on `PATH`). Every engine is registered whether or not
71
+ its dependency is present — a claimed file reports what is missing instead of
72
+ being copied — and nothing is imported until a file claims it. What each engine
73
+ does with `templates_undefined` differs by library and is tabulated in the
74
+ configuration guide; handlebars and mustache cannot fail on an undefined name
75
+ at all.
76
+ - `template_data()` / `DataView` / `jsonable()` — a lazy mapping view of the
77
+ render context for engines that read data rather than evaluate Python
78
+ (`{{ target.hostname }}` beside `{{ ctx.target.hostname }}`), and its
79
+ JSON-serialisable form for the subprocess engines.
80
+ - `SubprocessTemplate` — the base for an engine that renders through another
81
+ language's own tooling: program lookup with an env override, a temporary
82
+ template and values file, a timeout, and the program's stderr in the error.
83
+ - `ShellTemplate` is configured by subclassing, through three class attributes:
84
+ `EXT` (a string or a sequence, dots optional, case-insensitive; `None` or
85
+ `"*"` claims any suffix), `DELIMITER` (default `"%"`) and `PATTERN` —
86
+ `"braced"` (default, `%{NAME}`), `"unbraced"` (`%NAME`) or `"all"`.
87
+ - POSIX default expressions in the braced form: `%{NAME:-fallback}` substitutes
88
+ the fallback when the value is unset *or* empty, `%{NAME-fallback}` only when
89
+ it is unset. One layer of `'`/`"` quotes is stripped, the fallback is literal
90
+ text, and a placeholder with a default never fails whatever
91
+ `templates_undefined` says. `:=`, `:?` and `:+` are not implemented and stay
92
+ literal text.
93
+ - `CopyTemplate` — the copy-as-is engine described above, working in **bytes**:
94
+ the loader no longer decodes a file before knowing which engine claims it, so
95
+ the copy is byte-exact for anything (CRLF, latin-1, a binary) and
96
+ `ctx.render()` returns `bytes` for such a file. `EXT` narrows it to chosen
97
+ suffixes; `Template.BINARY` is the flag any engine can set to be handed raw
98
+ bytes. A non-UTF-8 file claimed by a *text* engine now raises
99
+ `TemplateEngineError` naming the file, where it used to fail inside the read.
100
+ - `Loader.find_source()` — the byte-returning counterpart of `get_source()`
101
+ (which keeps jinja2's text contract).
102
+ - **A template-engine registry**: `register_template_type()` (bare or as a
103
+ decorator, with an optional `priority=`), `unregister_template_type()`,
104
+ `TEMPLATE_TYPES` and `by_priority()`. `Loader(template_types=None)` — the new
105
+ default — uses every registered engine, so a `--load-module` plugin is picked
106
+ up without rebuilding the list, and the list is snapshotted at construction.
107
+ - **`Template.PRIORITY`**, so engine selection does not depend on registration
108
+ order: engines are consulted highest first, `DEFAULT_PRIORITY` (0) for an
109
+ ordinary engine and `FALLBACK_PRIORITY` (-100) for `CopyTemplate`. A plugin is
110
+ registered after the shipped engines, so without this every plugin would sit
111
+ behind the catch-all and never see a file. Equal priorities keep the order they
112
+ were given, so an explicit `template_types` list still means what it says.
113
+ - `Template.EXT` and `JinjaTemplate.EXT`, so suffix ownership is one declarative
114
+ attribute per engine, plus `netboot.templates.template_extensions()` to read it
115
+ back normalised.
8
116
 
9
117
  ## [0.2.4] - 2026-09-28
10
118
 
@@ -563,7 +671,9 @@ First packaged release: the `netboot` library with the `pixie` command line.
563
671
  config value construction no longer swallows non-`TypeError` errors; repo
564
672
  `joinpath` keeps `.local` a path so chained joins work.
565
673
 
566
- [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.4...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
676
+ [0.3.0]: https://github.com/jose-pr/netboot/compare/v0.2.4...v0.3.0
567
677
  [0.2.4]: https://github.com/jose-pr/netboot/compare/v0.2.3...v0.2.4
568
678
  [0.2.3]: https://github.com/jose-pr/netboot/compare/v0.2.2...v0.2.3
569
679
  [0.2.2]: https://github.com/jose-pr/netboot/compare/v0.2.1...v0.2.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netboot
3
- Version: 0.2.4
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
@@ -35,11 +35,15 @@ Requires-Dist: pyyaml; extra == 'config'
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: black; extra == 'dev'
37
37
  Requires-Dist: build; extra == 'dev'
38
+ Requires-Dist: chevron; extra == 'dev'
38
39
  Requires-Dist: dnspython; extra == 'dev'
39
40
  Requires-Dist: hatchling; extra == 'dev'
41
+ Requires-Dist: mako; extra == 'dev'
42
+ Requires-Dist: pybars3; extra == 'dev'
40
43
  Requires-Dist: pypureomapi; extra == 'dev'
41
44
  Requires-Dist: pytest; extra == 'dev'
42
45
  Requires-Dist: pytest-cov; extra == 'dev'
46
+ Requires-Dist: python-liquid; extra == 'dev'
43
47
  Requires-Dist: pywinrm; extra == 'dev'
44
48
  Requires-Dist: pyyaml; extra == 'dev'
45
49
  Requires-Dist: requests; extra == 'dev'
@@ -52,10 +56,18 @@ Provides-Extra: docs
52
56
  Requires-Dist: mkdocs; extra == 'docs'
53
57
  Requires-Dist: mkdocs-material; extra == 'docs'
54
58
  Requires-Dist: mkdocstrings[python]; extra == 'docs'
59
+ Provides-Extra: handlebars
60
+ Requires-Dist: pybars3; extra == 'handlebars'
55
61
  Provides-Extra: http
56
62
  Requires-Dist: requests; extra == 'http'
57
63
  Provides-Extra: kea
58
64
  Requires-Dist: requests; extra == 'kea'
65
+ Provides-Extra: liquid
66
+ Requires-Dist: python-liquid; extra == 'liquid'
67
+ Provides-Extra: mako
68
+ Requires-Dist: mako; extra == 'mako'
69
+ Provides-Extra: mustache
70
+ Requires-Dist: chevron; extra == 'mustache'
59
71
  Provides-Extra: ssh
60
72
  Requires-Dist: pathlib-next[sftp]; extra == 'ssh'
61
73
  Provides-Extra: winrm
@@ -99,11 +111,21 @@ Optional extras:
99
111
  | `netboot[dns]` | The `dnspython` resolver backend (best coverage; without it hostname targets still resolve via the system/`nslookup` fallbacks) |
100
112
  | `netboot[http]` | `http`/`https` repository services (`requests`); `file`/`tftp` repos and rendering need nothing extra |
101
113
  | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
114
+ | `netboot[mako]` | `*.mako` templates (`mako`) |
115
+ | `netboot[liquid]` | `*.liquid` templates (`python-liquid`) |
116
+ | `netboot[handlebars]` | `*.hbs` / `*.handlebars` templates (`pybars3`) |
117
+ | `netboot[mustache]` | `*.mustache` templates (`chevron`) |
102
118
  | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
103
119
  | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
104
120
  | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
105
121
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
106
122
 
123
+ `*.erb` and `*.epp` templates need no extra, but do need `ruby` or `puppet`
124
+ installed: they render through the real program, so an existing template behaves
125
+ as its author tested it. `PIXIE_RUBY` / `PIXIE_PUPPET` name it when it is not on
126
+ `PATH`. Jinja2 (`.j2`) and the `%{NAME}` shell engine (`.shtpl`) are built in, and
127
+ a file no engine claims is copied unchanged.
128
+
107
129
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
108
130
  [`pathlib_next`](https://github.com/jose-pr/pathlib-next) (URI-aware paths),
109
131
  [`yaconfiglib`](https://github.com/jose-pr/yaconfiglib) (layered YAML), and
@@ -175,7 +197,9 @@ python -m venv .venv/3.14
175
197
  ```
176
198
 
177
199
  Install every extra that has tests — without `http` the repository-service tests
178
- skip instead of running, which `-rs` makes visible. Develop on the latest Python
200
+ skip instead of running, which `-rs` makes visible (`dev` carries the template
201
+ engines' libraries for the same reason; the `.erb`/`.epp` tests skip unless
202
+ `ruby`/`puppet` are installed). Develop on the latest Python
179
203
  and also run the floor (3.9) before finishing a piece of work. More detail, plus
180
204
  the layout and CI notes, in
181
205
  [`AGENTS.md`](https://github.com/jose-pr/netboot/blob/main/AGENTS.md).
@@ -35,11 +35,21 @@ Optional extras:
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
37
  | `netboot[kea]` | The `kea://` DHCP backend (`requests`) |
38
+ | `netboot[mako]` | `*.mako` templates (`mako`) |
39
+ | `netboot[liquid]` | `*.liquid` templates (`python-liquid`) |
40
+ | `netboot[handlebars]` | `*.hbs` / `*.handlebars` templates (`pybars3`) |
41
+ | `netboot[mustache]` | `*.mustache` templates (`chevron`) |
38
42
  | `netboot[dhcpd]` | The `dhcpd://` DHCP backend over OMAPI (`pypureomapi`) |
39
43
  | `netboot[winrm]` | `windhcp://` over WinRM (`pywinrm`); over ssh it needs nothing |
40
44
  | `netboot[ssh]` | `dnsmasq://` with remote `sftp://` paths (`pathlib_next[sftp]`) |
41
45
  | `netboot[docs]` | Build the documentation site (`mkdocs`) |
42
46
 
47
+ `*.erb` and `*.epp` templates need no extra, but do need `ruby` or `puppet`
48
+ installed: they render through the real program, so an existing template behaves
49
+ as its author tested it. `PIXIE_RUBY` / `PIXIE_PUPPET` name it when it is not on
50
+ `PATH`. Jinja2 (`.j2`) and the `%{NAME}` shell engine (`.shtpl`) are built in, and
51
+ a file no engine claims is copied unchanged.
52
+
43
53
  Built on [`duho`](https://github.com/jose-pr/duho) (CLI/args/command discovery),
44
54
  [`pathlib_next`](https://github.com/jose-pr/pathlib-next) (URI-aware paths),
45
55
  [`yaconfiglib`](https://github.com/jose-pr/yaconfiglib) (layered YAML), and
@@ -111,7 +121,9 @@ python -m venv .venv/3.14
111
121
  ```
112
122
 
113
123
  Install every extra that has tests — without `http` the repository-service tests
114
- skip instead of running, which `-rs` makes visible. Develop on the latest Python
124
+ skip instead of running, which `-rs` makes visible (`dev` carries the template
125
+ engines' libraries for the same reason; the `.erb`/`.epp` tests skip unless
126
+ `ruby`/`puppet` are installed). Develop on the latest Python
115
127
  and also run the floor (3.9) before finishing a piece of work. More detail, plus
116
128
  the layout and CI notes, in
117
129
  [`AGENTS.md`](https://github.com/jose-pr/netboot/blob/main/AGENTS.md).
@@ -39,8 +39,22 @@ The engine and its context objects. Rendered from source via
39
39
 
40
40
  ::: netboot.templates.JinjaTemplate
41
41
 
42
+ ::: netboot.templates.MakoTemplate
43
+
44
+ ::: netboot.templates.LiquidTemplate
45
+
46
+ ::: netboot.templates.HandlebarsTemplate
47
+
48
+ ::: netboot.templates.MustacheTemplate
49
+
50
+ ::: netboot.templates.ERBTemplate
51
+
52
+ ::: netboot.templates.EppTemplate
53
+
42
54
  ::: netboot.templates.ShellTemplate
43
55
 
56
+ ::: netboot.templates.CopyTemplate
57
+
44
58
  ## Utilities
45
59
 
46
60
  ::: netboot.utils.dicts
@@ -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
 
@@ -176,11 +203,90 @@ not a mapping is an error naming the file.
176
203
 
177
204
  `templates` is a list of search paths (local or URI). For each render netboot looks
178
205
  for a file named by the target's MAC (`aa-bb-cc-...`), hostname, or IP — falling
179
- back to the bare template name. A `.j2` / `.jinja` / `.jinja2` file is rendered
180
- with Jinja2; anything else is rendered with the `%`-delimited shell engine, whose
181
- `%{UPPER_SNAKE}` placeholders come from the flattened context.
206
+ back to the bare template name.
207
+
208
+ **The suffix picks the engine, and the stem names the artifact.** A `.j2` /
209
+ `.jinja` / `.jinja2` file is rendered with Jinja2; a `.shtpl` file with the
210
+ `%`-delimited shell engine, whose `%{UPPER_SNAKE}` placeholders come from the
211
+ flattened context. Seven more engines ship for template trees that already exist
212
+ in another language:
213
+
214
+ | Engine | Suffixes | Needs | `templates_undefined` |
215
+ | ------ | -------- | ----- | --------------------- |
216
+ | `JinjaTemplate` | `.j2` `.jinja` `.jinja2` | — | all three |
217
+ | `MakoTemplate` | `.mako` | `netboot[mako]` | `strict`, `lenient`; `debug` behaves as `lenient` |
218
+ | `LiquidTemplate` | `.liquid` | `netboot[liquid]` | all three (`debug` names the variable in the output) |
219
+ | `HandlebarsTemplate` | `.hbs` `.handlebars` | `netboot[handlebars]` | **always lenient** — the library cannot fail |
220
+ | `MustacheTemplate` | `.mustache` | `netboot[mustache]` | **always lenient** (logs under `strict`) |
221
+ | `ERBTemplate` | `.erb` | `ruby` on `PATH` (or `PIXIE_RUBY`) | n/a — missing data is Ruby's `nil` |
222
+ | `EppTemplate` | `.epp` | `puppet` on `PATH` (or `PIXIE_PUPPET`) | n/a — missing data is Puppet's `undef` |
223
+ | `ShellTemplate` | `.shtpl` | — | all three |
224
+ | `CopyTemplate` | anything else | — | n/a — nothing is substituted |
225
+
226
+ Every optional engine is **registered whether or not its library is installed**,
227
+ so a `.liquid` file tells you to `pip install netboot[liquid]` instead of being
228
+ quietly copied. None of them is imported until a file claims it, so an install
229
+ that uses none pays nothing.
230
+
231
+ Jinja and mako evaluate Python, so a template reaches into `ctx` directly. The
232
+ data languages (liquid, handlebars, mustache) and the external ones (ERB, EPP)
233
+ get a **mapping view** of the same context instead: `{{ ctx.target.hostname }}`
234
+ and `{{ target.hostname }}` both resolve, an address or a path arrives as the
235
+ string a template would have printed, and the engine's own machinery
236
+ (`ctx._netboot_`, the renderer) is left out. ERB additionally sets each top-level
237
+ name as an instance variable (`@target`), and EPP as a parameter (`$target`).
238
+
239
+ ERB and EPP run the real `ruby` and `puppet` as a subprocess, with the context
240
+ marshalled to JSON — which is the point: an existing `.erb` renders the way its
241
+ author tested it, rather than the way a reimplementation guesses. Set `PIXIE_RUBY`
242
+ or `PIXIE_PUPPET` if the program is installed but not on `PATH`. Because a template is also found by its stem, the file behind
243
+ an artifact called `boot.cfg` is `boot.cfg.shtpl` and `ctx.render("boot.cfg")`
244
+ still finds it.
245
+
246
+ Anything else is **copied as is** — a static `grub.cfg`, an EFI binary or a
247
+ license file is a template that needs no engine. The copy engine works in
248
+ **bytes**: the loader never decodes the file, so the result is byte-exact
249
+ whatever it holds (CRLF line endings, latin-1 text, something that is not text at
250
+ all) and `ctx.render()` returns `bytes` for it rather than `str`.
251
+
252
+ Before 0.3.0 the shell engine claimed every suffix, so a file that *does* carry
253
+ `%{NAME}` placeholders now ships them unrendered: rename it to `*.shtpl` once.
254
+ That is the one mistake a copy can hide, so a copy that still finds placeholders
255
+ logs a warning naming the rename.
182
256
 
183
257
  Only the braced form is substituted, so a bare `%word` is left alone — a kickstart
184
258
  file keeps its `%packages`, `%pre`, `%post` and `%end` sections, and a script keeps
185
259
  `date +%Y`. Write `%%` for a literal `%` next to a brace, `%{NAME}` to substitute.
186
- An unknown `%{NAME}` is an error.
260
+ An unknown `%{NAME}` follows `templates_undefined` (above).
261
+
262
+ `%{NAME:-fallback}` renders `fallback` when `NAME` is unset **or empty**, and
263
+ `%{NAME-fallback}` only when it is unset — the two POSIX forms, so a template can
264
+ carry its own default instead of requiring the variable. One layer of `'`/`"`
265
+ quotes is stripped (`%{NAME:-'a default'}`), the fallback is literal text (no
266
+ nested placeholders, and no `}` inside it), and a placeholder that has a default
267
+ never fails whatever `templates_undefined` says. The other POSIX forms (`:=`,
268
+ `:?`, `:+`) are not implemented and stay literal text.
269
+
270
+ The engine is configured by subclassing, not by config, for a tree that uses
271
+ another convention:
272
+
273
+ ```python
274
+ from netboot.templates.shell import ShellTemplate
275
+
276
+ class DollarTemplate(ShellTemplate):
277
+ EXT = (".tpl", ".cfg") # a single string is fine; `None` claims any suffix
278
+ DELIMITER = "$"
279
+ PATTERN = "all" # 'braced' (default), 'unbraced', or 'all'
280
+ ```
281
+
282
+ Register the class with `netboot.templates.register_template_type` (see
283
+ [Extending](extending.md#custom-template-engines)) or pass it in
284
+ `Loader(..., template_types=[...])`. Engines are consulted in `PRIORITY` order,
285
+ highest first, so a registered engine is asked before the catch-all
286
+ `CopyTemplate` whatever order they were registered in. The default is every
287
+ registered engine: `JinjaTemplate`, `ShellTemplate`, then `CopyTemplate`. Narrow
288
+ it — dropping the copy engine, or giving it a suffix list of its own — and a file
289
+ nothing claims raises `netboot.templates.TemplateEngineError`, naming the file and
290
+ what each engine handles. A file that is not valid UTF-8 raises the same error when the engine
291
+ that claims it needs text; set `BINARY = True` on an engine to be handed the raw
292
+ bytes instead, as `CopyTemplate` does.
@@ -0,0 +1,215 @@
1
+ # Extending
2
+
3
+ ## Event hooks
4
+
5
+ Pass `hooks=[...]` to `Pixie(...)` — each entry is a callable or a
6
+ `"module.function"` import string. A hook
7
+
8
+ ```python
9
+ def my_hook(event, netboot, value, kwargs):
10
+ ...
11
+ return value
12
+ ```
13
+
14
+ is invoked for every `PixieEvent` and may transform the value flowing through it.
15
+ Events fire around object creation (`NewPixieObject`, `SetPixieProperty`,
16
+ `PixieInitiated`), lookup (`LookupTarget`, `FoundTarget`, `FoundTargetImage`,
17
+ `FoundTargetDhcpzone`), context construction (`PixieContextForTarget`), and the
18
+ init/complete lifecycle (`StartPixieInitialize` … `EndPixieComplete`). This is the
19
+ seam for customising how targets/images/zones are resolved and how the render
20
+ context is assembled.
21
+
22
+ ### The contract
23
+
24
+ - **Always return.** Every hook is handed the previous hook's return value, and
25
+ the last return value is what netboot uses. A hook that falls off the end
26
+ returns `None`, which is then what the engine gets — a lookup hook that
27
+ forgets to `return value` turns every lookup into "not found".
28
+ - **The signature is positional**, and `kwargs` arrives as a plain `dict` (the
29
+ fourth argument), not as `**kwargs`.
30
+ - **`netboot` is `None` for `NewPixieObject`**, which fires before the instance
31
+ exists; `value` there is the class about to be instantiated, and returning a
32
+ subclass is how you swap in your own.
33
+ - **`value` and `kwargs` differ per event.** `SetPixieProperty` gets a
34
+ `(name, value)` tuple plus `origin=`/`rawvalue=`; the lookup events get the
35
+ object found (or the query, for `LookupTarget`) plus `target=`; the lifecycle
36
+ events get the target, then the built context.
37
+ - **`PixieEvent` is a string enum whose values are prefixed** — the value of
38
+ `PixieEvent.LookupTarget` is the string `"PixieEvent.LookupTarget"`. Compare
39
+ against the enum member, not a bare name.
40
+
41
+ ```python
42
+ from netboot import PixieEvent
43
+
44
+ def only_on_lookup(event, netboot, value, kwargs):
45
+ if event is not PixieEvent.FoundTarget:
46
+ return value # pass everything else through
47
+ return value or fallback_target()
48
+ ```
49
+
50
+ ## Custom template engines
51
+
52
+ An engine is a class with two things: the suffixes it claims (`EXT`) and
53
+ `render()`. Register it and it joins the engines every `Loader` consults.
54
+
55
+ ```python
56
+ from netboot.templates import Template, register_template_type
57
+
58
+ @register_template_type
59
+ class MakoTemplate(Template):
60
+ EXT = ".mako" # a string, or a sequence of them
61
+
62
+ def __init__(self, template: str) -> None:
63
+ self._source = template
64
+
65
+ def render(self, **extras):
66
+ ctx = self._globals_["ctx"] # the PixieContext being rendered
67
+ return my_mako_render(self._source, ctx)
68
+ ```
69
+
70
+ Import the module before anything renders — `--load-module your.plugin`, the same
71
+ flag a DHCP backend plugin needs.
72
+
73
+ **Priority, not registration order, decides who gets a file.** Engines are
74
+ consulted highest `PRIORITY` first, and a plugin is necessarily registered
75
+ *after* the shipped engines — if order decided, every plugin would sit behind the
76
+ catch-all `CopyTemplate` and never see a file. So:
77
+
78
+ | `PRIORITY` | Meaning |
79
+ | ---------- | ------- |
80
+ | `DEFAULT_PRIORITY` (`0`) | an ordinary engine claiming its own suffixes — the default, and enough to outrank the copy engine |
81
+ | above `0` | override a shipped engine on a suffix it also claims (`priority=10` to take `.j2` from `JinjaTemplate`) |
82
+ | `FALLBACK_PRIORITY` (`-100`) | a last resort, where `CopyTemplate` sits |
83
+
84
+ Engines of *equal* priority keep the order they were given, so an explicit
85
+ `Loader(..., template_types=[...])` still means what it says. Set the priority on
86
+ the class, or at registration for a class you do not own:
87
+
88
+ ```python
89
+ register_template_type(SomeonesEngine, priority=10)
90
+ ```
91
+
92
+ `unregister_template_type(cls)` removes one again, and a `Loader` snapshots the
93
+ registry when it is built, so importing a plugin halfway through a run never
94
+ changes an engine already in use.
95
+
96
+ Other attributes on the contract: **`BINARY = True`** to be handed the file's raw
97
+ `bytes` instead of decoded text (and to return bytes, as `CopyTemplate` does),
98
+ and `EXT = None` to claim *every* suffix — which only makes sense together with a
99
+ low priority.
100
+
101
+ The shipped engines are the worked examples, and they cover the three shapes an
102
+ engine takes:
103
+
104
+ - **evaluates Python** (`JinjaTemplate`, `MakoTemplate`) — hand the engine `ctx`
105
+ itself and let the template reach into it.
106
+ - **reads data only** (`LiquidTemplate`, `HandlebarsTemplate`,
107
+ `MustacheTemplate`) — build the namespace with
108
+ `netboot.templates.template_data(self._globals_, extras)`, which returns a lazy
109
+ mapping view of the context. liquid cannot traverse attributes at all, so this
110
+ is not optional for that class of engine.
111
+ - **shells out** (`ERBTemplate`, `EppTemplate`) — subclass
112
+ `SubprocessTemplate`, set `COMMAND`, `ENV_VAR` and `EXT`, and implement
113
+ `command(program, template_path, values_path)`. The base marshals the context
114
+ with `jsonable()`, writes template and values into a temporary directory, runs
115
+ the program with a timeout, and turns a non-zero exit into a
116
+ `TemplateEngineError` carrying the program's own stderr.
117
+
118
+ An engine that needs an optional library imports it in a helper, not at module
119
+ scope, and raises `ImportError` naming the extra — that way the class can be
120
+ registered unconditionally, and a file it claims reports what is missing rather
121
+ than falling through to the copy engine.
122
+
123
+ ## Custom DHCP backends
124
+
125
+ `netboot.dhcp.DhcpServer` dispatches on the URI scheme of a zone's `dhcpservers`
126
+ entry: a subclass whose lowercased class name equals the scheme handles it.
127
+
128
+ ```python
129
+ from netboot.dhcp import DhcpServer
130
+
131
+ class dnsmasq(DhcpServer): # handles dnsmasq://...
132
+ def add_target(self, ctx):
133
+ ... # arm DHCP for ctx.target
134
+ def remove_target(self, ctx):
135
+ ... # disarm it
136
+ ```
137
+
138
+ netboot ships four backends — `netboot.dhcp.dnsmasq`, `.kea`, `.dhcpd` and
139
+ `.windhcp` — and they are the worked examples: one writes files (locally or over
140
+ `sftp://`), one speaks a REST API, one a binary protocol, and one runs
141
+ PowerShell over ssh or WinRM. A backend gets its client options from
142
+ `self.options_for(ctx)` and translates them; see the configuration guide for
143
+ what an operator writes.
144
+
145
+ Subclassing at any depth is honoured, so a backend may share an intermediate
146
+ base. Import your plugin module before the config builds the zones — pass
147
+ `--load-module your.plugin` (repeat or colon-separate for several) so the
148
+ `DhcpServer` subclass is registered when `dnsmasq://...` is resolved.
149
+
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
+