netboot 0.2.1__tar.gz → 0.2.3__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.1 → netboot-0.2.3}/CHANGELOG.md +37 -1
  2. {netboot-0.2.1 → netboot-0.2.3}/PKG-INFO +3 -3
  3. {netboot-0.2.1 → netboot-0.2.3}/pyproject.toml +8 -8
  4. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/AGENTS.md +20 -13
  5. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/engine.py +29 -30
  6. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/utils/net.py +2 -2
  7. {netboot-0.2.1 → netboot-0.2.3}/tests/test_config_discovery.py +19 -0
  8. {netboot-0.2.1 → netboot-0.2.3}/tests/test_engine_robustness.py +27 -16
  9. {netboot-0.2.1 → netboot-0.2.3}/.gitignore +0 -0
  10. {netboot-0.2.1 → netboot-0.2.3}/AGENTS.md +0 -0
  11. {netboot-0.2.1 → netboot-0.2.3}/LICENSE +0 -0
  12. {netboot-0.2.1 → netboot-0.2.3}/README.md +0 -0
  13. {netboot-0.2.1 → netboot-0.2.3}/benchmarks/README.md +0 -0
  14. {netboot-0.2.1 → netboot-0.2.3}/benchmarks/bench_netboot.py +0 -0
  15. {netboot-0.2.1 → netboot-0.2.3}/benchmarks/results/netboot.json +0 -0
  16. {netboot-0.2.1 → netboot-0.2.3}/docs/api.md +0 -0
  17. {netboot-0.2.1 → netboot-0.2.3}/docs/changelog.md +0 -0
  18. {netboot-0.2.1 → netboot-0.2.3}/docs/cli.md +0 -0
  19. {netboot-0.2.1 → netboot-0.2.3}/docs/configuration.md +0 -0
  20. {netboot-0.2.1 → netboot-0.2.3}/docs/extending.md +0 -0
  21. {netboot-0.2.1 → netboot-0.2.3}/docs/index.md +0 -0
  22. {netboot-0.2.1 → netboot-0.2.3}/examples/README.md +0 -0
  23. {netboot-0.2.1 → netboot-0.2.3}/examples/config/pixie.yaml +0 -0
  24. {netboot-0.2.1 → netboot-0.2.3}/examples/plugins/recording.py +0 -0
  25. {netboot-0.2.1 → netboot-0.2.3}/examples/templates/debian/boot.cfg +0 -0
  26. {netboot-0.2.1 → netboot-0.2.3}/examples/templates/debian/install.ks.j2 +0 -0
  27. {netboot-0.2.1 → netboot-0.2.3}/mkdocs.yml +0 -0
  28. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/__init__.py +0 -0
  29. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/__main__.py +0 -0
  30. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/_version.py +0 -0
  31. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/cmds/__init__.py +0 -0
  32. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/cmds/complete.py +0 -0
  33. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/cmds/initiate.py +0 -0
  34. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/content/__init__.py +0 -0
  35. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/__init__.py +0 -0
  36. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/dhcpd.py +0 -0
  37. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/dnsmasq.py +0 -0
  38. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/kea.py +0 -0
  39. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/options.py +0 -0
  40. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/dhcp/windhcp.py +0 -0
  41. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/logging.py +0 -0
  42. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/main.py +0 -0
  43. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/py.typed +0 -0
  44. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/templates/__init__.py +0 -0
  45. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/templates/common.py +0 -0
  46. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/templates/jinja.py +0 -0
  47. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/templates/shell.py +0 -0
  48. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/utils/__init__.py +0 -0
  49. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/utils/config.py +0 -0
  50. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/utils/dicts.py +0 -0
  51. {netboot-0.2.1 → netboot-0.2.3}/src/netboot/utils/misc.py +0 -0
  52. {netboot-0.2.1 → netboot-0.2.3}/tests/conftest.py +0 -0
  53. {netboot-0.2.1 → netboot-0.2.3}/tests/test_api_contracts.py +0 -0
  54. {netboot-0.2.1 → netboot-0.2.3}/tests/test_cli.py +0 -0
  55. {netboot-0.2.1 → netboot-0.2.3}/tests/test_cli_discovery.py +0 -0
  56. {netboot-0.2.1 → netboot-0.2.3}/tests/test_content.py +0 -0
  57. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp.py +0 -0
  58. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp_dhcpd.py +0 -0
  59. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp_dnsmasq.py +0 -0
  60. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp_kea.py +0 -0
  61. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp_options.py +0 -0
  62. {netboot-0.2.1 → netboot-0.2.3}/tests/test_dhcp_windhcp.py +0 -0
  63. {netboot-0.2.1 → netboot-0.2.3}/tests/test_hooks.py +0 -0
  64. {netboot-0.2.1 → netboot-0.2.3}/tests/test_lookup.py +0 -0
  65. {netboot-0.2.1 → netboot-0.2.3}/tests/test_render.py +0 -0
  66. {netboot-0.2.1 → netboot-0.2.3}/tests/test_target.py +0 -0
  67. {netboot-0.2.1 → netboot-0.2.3}/tests/test_utils.py +0 -0
@@ -6,6 +6,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.3] - 2026-09-23
10
+
11
+ ### Fixed
12
+ - One DHCP server being offline no longer fails `pixie initiate` or
13
+ `pixie complete`. Arming is meant to be best effort, but since 0.2.0 it
14
+ stopped at the first failing server and rolled the others back. `initiate`
15
+ now tries every server in the zone, logs each failure as a warning, and fails
16
+ only when no server could be armed. `complete` logs a failing server as a
17
+ warning and still exits 0, so read the log if you need to know every server
18
+ was disarmed.
19
+
20
+ ## [0.2.2] - 2026-09-21
21
+
22
+ netboot moves to netimps 0.3.1 and yaconfiglib 0.13. If a plugin, hook or
23
+ template compares `target.mac` with text, read the `MACAddress` entry below.
24
+
25
+ ### Changed
26
+ - Dependencies move to their new series: `netimps>=0.3.1,<0.4` and
27
+ `yaconfiglib>=0.13.0,<0.14`. The old ceilings made netboot uninstallable
28
+ beside either release. netimps 0.3.0 is skipped on purpose: it raised from
29
+ `resolve()` when no resolver was reachable, which would have failed every
30
+ command at startup, and 0.3.1 answers `[]` again. netboot uses none of the
31
+ yaconfiglib APIs 0.13.0 removed.
32
+ - **`MACAddress` no longer equals a `str`** (netimps 0.3.0, re-exported as
33
+ `netboot.utils.net.MACAddress`). `target.mac == "aa:bb:cc:dd:ee:ff"` is now
34
+ `False` — in a plugin, a hook or a Jinja template alike. Compare with
35
+ `MACAddress.try_parse(text) == target.mac`. In exchange a MAC is found in a
36
+ set or dict whichever spelling built it. netboot's own target lookup already
37
+ compared parsed MACs and is unaffected. A MAC that mixes separators
38
+ (`00-11:22-33:44-55`) is now rejected, and `aa.bb.cc.dd.ee.ff` is accepted.
39
+ - `resolve()` keeps trying backends after an empty answer, so a hostname
40
+ target known only to the hosts file or NSS now gets its IP on Windows and
41
+ macOS. A name that exists nowhere costs two or three lookups instead of one.
42
+
9
43
  ## [0.2.1] - 2026-09-17
10
44
 
11
45
  netboot ships DHCP backends. Additive: nothing documented changed behaviour,
@@ -520,7 +554,9 @@ First packaged release: the `netboot` library with the `pixie` command line.
520
554
  config value construction no longer swallows non-`TypeError` errors; repo
521
555
  `joinpath` keeps `.local` a path so chained joins work.
522
556
 
523
- [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.1...HEAD
557
+ [Unreleased]: https://github.com/jose-pr/netboot/compare/v0.2.3...HEAD
558
+ [0.2.3]: https://github.com/jose-pr/netboot/compare/v0.2.2...v0.2.3
559
+ [0.2.2]: https://github.com/jose-pr/netboot/compare/v0.2.1...v0.2.2
524
560
  [0.2.1]: https://github.com/jose-pr/netboot/compare/v0.2.0...v0.2.1
525
561
  [0.2.0]: https://github.com/jose-pr/netboot/compare/v0.1.3...v0.2.0
526
562
  [0.1.3]: https://github.com/jose-pr/netboot/compare/v0.1.2...v0.1.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: netboot
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "netboot"
7
- version = "0.2.1"
7
+ version = "0.2.3"
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]
@@ -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
@@ -149,12 +150,12 @@ is the same object. `netboot.netutils` remains an alias for
149
150
  process's working directory; an absolute path or a URI is used as given.
150
151
  - **`.pxe_init(netboot) -> Self`** / **`.pxe_complete(netboot) -> Self`** —
151
152
  arm/disarm every `dhcpzone.dhcpservers` for this context; called by
152
- `Pixie.initialize`/`.complete`, not usually invoked directly. `pxe_init`
153
- is **all or nothing**: if a backend raises, the ones already armed are
154
- rolled back before the error propagates, so a target is never left armed
155
- on some servers and not others. `pxe_complete` is the opposite — every
156
- backend is tried even if one fails, and the first error is raised
157
- afterwards, because stopping early would leave the rest armed.
153
+ `Pixie.initialize`/`.complete`, not usually invoked directly. Both try
154
+ every backend. `pxe_init` logs a backend that raises as a **warning** and
155
+ carries on; it raises (the first error) only when **no** backend could be
156
+ armed, and nothing is rolled back, so a target may end up armed on some
157
+ servers and not others. `pxe_complete` never raises for a backend: each
158
+ failure is logged as a warning and the rest are still disarmed.
158
159
 
159
160
  ## DHCP (`netboot.dhcp`)
160
161
 
@@ -337,7 +338,8 @@ servers identifies a reservation by it.
337
338
  - **`Host(address: str | Host | None = None)`** — a hostname-or-IP repo
338
339
  address. **`.try_ip() -> IPAddress | str`** — resolves to an `IPAddress`
339
340
  (an IP literal as-is, a hostname through one forward lookup); falls back to
340
- 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 —
341
343
  without it netimps resolves through its system/`nslookup` backends.
342
344
  Equality/hash by `.address`.
343
345
  - **`flatten(map, _prefix="") -> dict`** — recursively flattens a
@@ -368,13 +370,18 @@ servers identifies a reservation by it.
368
370
  - `parse(value, type, **kw)` raises on bad input; `try_parse(value, type)`
369
371
  returns `None` instead; `is_valid(value, type)` returns a bool. Networks
370
372
  parse non-strict, so `"10.0.0.5/24"` normalises rather than raising.
371
- - `MACAddress` — colon/hyphen/Cisco-dot/bare text, `int` or `bytes`;
372
- `.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`.
373
377
  - `resolve(query, rdtype=None)` → a list of **native** records (`A`/`AAAA`
374
378
  are `IPv4Address`/`IPv6Address`, not strings); `[]` on a genuine lookup
375
379
  failure, but a malformed query or unknown record type raises `ValueError`
376
380
  rather than looking like "no such record". `rdtype=None` auto-selects:
377
- `"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.
378
385
  - `ping(dst)` → a `PingResult` that is truthy on success and also carries
379
386
  `.rtt_ms`/`.ttl`.
380
387
  - `get_interfaces()` / `iter_addresses()` — real adapter enumeration with
@@ -7,6 +7,7 @@ whole implementation.
7
7
 
8
8
  import datetime
9
9
  import enum as _enum
10
+ import logging
10
11
  import typing as _ty
11
12
  from argparse import Namespace
12
13
  from copy import deepcopy
@@ -199,51 +200,49 @@ class PixieContext(Namespace):
199
200
  return self.repos.get(resource.src)
200
201
 
201
202
  def pxe_init(self, config: "Pixie"):
202
- """Arm every DHCP backend for this target, all or nothing.
203
+ """Arm every DHCP backend for this target, succeeding if any one does.
203
204
 
204
- A target armed on two of three backends is worse than one armed on
205
- none: it may boot into an installer from one server while another
206
- hands out its normal lease. So a failure rolls the already-armed
207
- backends back before re-raising.
205
+ Every backend is tried. A backend that fails is logged as a warning and
206
+ skipped, so one server being offline does not block the others; the
207
+ first error is raised only when no backend could be armed.
208
208
  """
209
- armed = []
209
+ first_error = None
210
+ armed = 0
210
211
  for dhcpserver in self.dhcpzone.dhcpservers:
211
212
  try:
212
213
  dhcpserver.add_target(self)
213
- except Exception:
214
- for done in reversed(armed):
215
- try:
216
- done.remove_target(self)
217
- except Exception: # keep unwinding; report the first cause
218
- LOGGER.exception(
219
- "rollback failed for %s on %s",
220
- self.target._id,
221
- getattr(done, "uri", done),
222
- )
223
- raise
224
- armed.append(dhcpserver)
214
+ except Exception as exc:
215
+ LOGGER.warning(
216
+ "could not arm %s on %s: %s",
217
+ self.target._id,
218
+ getattr(dhcpserver, "uri", dhcpserver),
219
+ exc,
220
+ exc_info=LOGGER.isEnabledFor(logging.DEBUG),
221
+ )
222
+ first_error = first_error or exc
223
+ else:
224
+ armed += 1
225
+ if first_error is not None and not armed:
226
+ raise first_error
225
227
  return self
226
228
 
227
229
  def pxe_complete(self, config: "Pixie"):
228
- """Disarm every DHCP backend, continuing past a failure.
230
+ """Disarm every DHCP backend; a failure is a warning, never an error.
229
231
 
230
- Cleanup is the opposite case from arming: stopping at the first error
231
- would leave the remaining backends armed, so every backend is tried and
232
- the first error is raised once they have all had their turn.
232
+ Every backend is tried, and one that fails is logged and skipped, so
233
+ the rest are still disarmed.
233
234
  """
234
- first_error = None
235
235
  for dhcpserver in self.dhcpzone.dhcpservers:
236
236
  try:
237
237
  dhcpserver.remove_target(self)
238
238
  except Exception as exc:
239
- LOGGER.exception(
240
- "could not disarm %s on %s",
239
+ LOGGER.warning(
240
+ "could not disarm %s on %s: %s",
241
241
  self.target._id,
242
242
  getattr(dhcpserver, "uri", dhcpserver),
243
+ exc,
244
+ exc_info=LOGGER.isEnabledFor(logging.DEBUG),
243
245
  )
244
- first_error = first_error or exc
245
- if first_error is not None:
246
- raise first_error
247
246
  return self
248
247
 
249
248
  def _template_names(self, suffix: Union[list[str], str], **options) -> list[str]:
@@ -706,7 +705,7 @@ class Pixie:
706
705
  def initialize(self, target: "PixieTarget"):
707
706
  """Arm DHCP for `target` and return its context.
708
707
 
709
- All or nothing: a backend that fails rolls back the ones already armed.
708
+ A backend that fails is a warning; it raises only if none could be armed.
710
709
  """
711
710
  target = self.hook(PixieEvent.StartPixieInitialize, target)
712
711
  ctx = self.make_context(target)
@@ -716,7 +715,7 @@ class Pixie:
716
715
  def complete(self, target: "PixieTarget"):
717
716
  """Disarm DHCP for `target` and return its context.
718
717
 
719
- Every backend is tried even if one fails; the first error is raised after.
718
+ Every backend is tried; one that fails is logged as a warning.
720
719
  """
721
720
  target = self.hook(PixieEvent.StartPixieComplete, target)
722
721
  ctx = self.make_context(target, require_image=False)
@@ -3,8 +3,8 @@
3
3
  The IP/MAC machinery used to live in an in-tree copy of an earlier version of
4
4
  that library. It has since been published, gaining fixes this copy never had --
5
5
  real prefix lengths and MTU in interface enumeration, DNS errors that surface
6
- instead of being swallowed, and a ``ping`` that behaves the same on every
7
- platform -- so the copy is gone.
6
+ instead of being swallowed, and a ``ping`` that speaks each platform's own
7
+ flags -- so the copy is gone.
8
8
 
9
9
  This module re-exports netimps under netboot's own name rather than wrapping
10
10
  it: the point of adopting a library is to use its vocabulary. Only
@@ -90,3 +90,22 @@ def test_resolve_auto_selects_the_record_type():
90
90
  "netimps.resolve no longer defaults rdtype to None (auto-select); "
91
91
  "Host.try_ip relies on that, netimps >= 0.2.1"
92
92
  )
93
+
94
+
95
+ def test_resolve_is_not_strict_by_default():
96
+ """Guards `netimps>=0.3.1`: a resolver outage answers `[]`, not a raise.
97
+
98
+ 0.3.0 raised `ResolutionError` from `resolve()` when no backend could even
99
+ ask; 0.3.1 made that opt-in with `strict=True`. `PixieTarget` handles `[]`
100
+ with a warning, but a raise would escape its construction -- and every
101
+ target is built at startup, so one outage would fail every command.
102
+ """
103
+ strict = inspect.signature(netutils.resolve).parameters.get("strict")
104
+ assert strict is not None, (
105
+ "netimps.resolve has no strict parameter: an unreachable resolver "
106
+ "raises instead of answering [] (netimps >= 0.3.1)"
107
+ )
108
+ assert strict.default is False, (
109
+ "netimps.resolve is strict by default; PixieTarget relies on [] for "
110
+ "an unreachable resolver (netimps >= 0.3.1)"
111
+ )
@@ -1,7 +1,7 @@
1
1
  """Engine behaviour under partly-broken input and partly-failing backends.
2
2
 
3
3
  Each case here is a crash or a silent wrong answer the 2026-09-17 review
4
- reproduced: a half-armed target, a retired image blocking cleanup, a global
4
+ reproduced: an offline DHCP server, a retired image blocking cleanup, a global
5
5
  that replaced the render context, a zone chosen by declaration order.
6
6
  """
7
7
 
@@ -61,28 +61,39 @@ class rollback(_Recorder): # scheme `rollback://`
61
61
  pass
62
62
 
63
63
 
64
- def test_a_failing_backend_rolls_back_the_ones_already_armed():
64
+ def test_arming_survives_a_failing_backend_with_a_warning(caplog):
65
65
  engine = _engine()
66
66
  zone = engine.dhcpzones["lan"]
67
- zone.dhcpservers[1].fail_on = "add"
68
- with pytest.raises(RuntimeError):
67
+ zone.dhcpservers[0].fail_on = "add"
68
+ with caplog.at_level(logging.WARNING, logger="netboot"):
69
69
  engine.initialize(engine.lookup_target("host1"))
70
- # b failed, so a must not be left armed.
71
- assert _Recorder.calls == [
72
- ("arm", "rollback://a"),
73
- ("arm", "rollback://b"),
74
- ("disarm", "rollback://a"),
75
- ]
70
+ # a is offline; b is still armed and nothing is rolled back.
71
+ assert _Recorder.calls == [("arm", "rollback://a"), ("arm", "rollback://b")]
72
+ assert "could not arm host1 on rollback://a" in caplog.text
76
73
 
77
74
 
78
- def test_disarm_continues_past_a_failure_then_raises():
75
+ def test_arming_raises_when_every_backend_fails():
79
76
  engine = _engine()
80
- zone = engine.dhcpzones["lan"]
81
- zone.dhcpservers[0].fail_on = "remove"
82
- with pytest.raises(RuntimeError):
77
+ for server in engine.dhcpzones["lan"].dhcpservers:
78
+ server.fail_on = "add"
79
+ with pytest.raises(RuntimeError, match="rollback://a refused to arm"):
80
+ engine.initialize(engine.lookup_target("host1"))
81
+ assert _Recorder.calls == [("arm", "rollback://a"), ("arm", "rollback://b")]
82
+
83
+
84
+ def test_disarm_failures_are_warnings(caplog):
85
+ engine = _engine()
86
+ for server in engine.dhcpzones["lan"].dhcpservers:
87
+ server.fail_on = "remove"
88
+ with caplog.at_level(logging.WARNING, logger="netboot"):
83
89
  engine.complete(engine.lookup_target("host1"))
84
- # The second backend is still disarmed despite the first failing.
85
- assert ("disarm", "rollback://b") in _Recorder.calls
90
+ # Every backend is tried, and none of the failures is raised.
91
+ assert _Recorder.calls == [
92
+ ("disarm", "rollback://a"),
93
+ ("disarm", "rollback://b"),
94
+ ]
95
+ assert "could not disarm host1 on rollback://a" in caplog.text
96
+ assert "could not disarm host1 on rollback://b" in caplog.text
86
97
 
87
98
 
88
99
  def test_complete_works_when_the_image_is_no_longer_configured(caplog):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes