manta-node 0.6b5.dev706__py3-none-any.whl → 0.6b5.dev707__py3-none-any.whl

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.
@@ -4,7 +4,7 @@ This file is auto-generated at build time by scripts/generate_version.py.
4
4
  Do not edit manually.
5
5
  """
6
6
 
7
- __version__ = "2026.09.24.8d51d71c"
8
- __build_date__ = "2026.09.24"
9
- __commit_id__ = "8d51d71c"
7
+ __version__ = "2026.09.25.d14aac9a"
8
+ __build_date__ = "2026.09.25"
9
+ __commit_id__ = "d14aac9a"
10
10
  __package_version__ = "0.6b5"
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import time
21
21
  from collections.abc import Callable
22
22
  from dataclasses import dataclass
23
+ from pathlib import Path
23
24
  from typing import Protocol
24
25
 
25
26
  from ..seam import SeamState, SwitchOutcome, UpdateRequest
@@ -161,11 +162,13 @@ def run_switch(
161
162
  try:
162
163
  if not _within(bounds.switch, lambda: version_tree.commit(target)):
163
164
  clear_inflight()
165
+ _reclaim_candidate(bounds, target, last_good_dir)
164
166
  return _outcome(
165
167
  request, SeamState.FAILED, "switch exceeded its bound (repoint failed)"
166
168
  )
167
169
  except VersionTreeError as exc:
168
170
  clear_inflight()
171
+ _reclaim_candidate(bounds, target, last_good_dir)
169
172
  return _outcome(request, SeamState.FAILED, f"switch failed: {exc}")
170
173
 
171
174
  # RESTART onto the committed version, then wait for the return window. A
@@ -173,6 +176,7 @@ def run_switch(
173
176
  # the record rather than letting the exception escape.
174
177
  if not _within(bounds.switch, restart):
175
178
  clear_inflight()
179
+ _reclaim_candidate(bounds, target, last_good_dir)
176
180
  return _outcome(request, SeamState.DOWN, "restart failed after the switch")
177
181
 
178
182
  if is_back(record.deadline_epoch):
@@ -195,6 +199,7 @@ def run_switch(
195
199
  if last_good is None:
196
200
  # Nothing to revert to: surface DOWN rather than claim success.
197
201
  clear_inflight()
202
+ _reclaim_candidate(bounds, target, last_good_dir)
198
203
  return _outcome(
199
204
  request, SeamState.DOWN, "no return and no last-good version to revert to"
200
205
  )
@@ -204,8 +209,13 @@ def run_switch(
204
209
  raise LifecycleError("restart after revert failed")
205
210
  except (VersionTreeError, LifecycleError) as exc:
206
211
  clear_inflight()
212
+ _reclaim_candidate(bounds, target, last_good_dir)
207
213
  return _outcome(request, SeamState.DOWN, f"revert failed: {exc}")
208
214
  clear_inflight()
215
+ # The candidate was superseded the moment the switch did not land: it is
216
+ # reclaimed even though the switch failed, so repeated failing switches do
217
+ # not grow the tree past the bound (NF-12). current/last-good are protected.
218
+ _reclaim_candidate(bounds, target, last_good_dir)
209
219
  return _outcome(
210
220
  request,
211
221
  SeamState.REVERTED,
@@ -214,6 +224,29 @@ def run_switch(
214
224
  )
215
225
 
216
226
 
227
+ def _reclaim_candidate(
228
+ bounds: Bounds, candidate: Path, last_good_dir: Path | None
229
+ ) -> None:
230
+ """Reclaim after a switch that did NOT land: the candidate is superseded.
231
+
232
+ Called on every FAILED/REVERTED/DOWN path after staging, so repeated failing
233
+ switches do not grow the tree past ``retention_dirs`` (NF-12). The candidate is
234
+ removed first, then ``reclaim`` bounds the rest, protecting ``current`` and the
235
+ last good.
236
+
237
+ Whether the candidate is still current depends on the branch: a failed commit
238
+ leaves it non-current, but a switch whose commit succeeded and whose RESTART
239
+ then failed (or found no last good to revert to) has ``target`` AS ``current``.
240
+ ``remove_version`` therefore refuses to delete whatever ``current`` names, and
241
+ that check — not any assumption here — is what keeps the node runnable on those
242
+ DOWN paths. Keep it if the candidate could ever be current.
243
+ """
244
+ version_tree.remove_version(candidate)
245
+ current = version_tree.selected()
246
+ protect = {p for p in (current, last_good_dir) if p is not None}
247
+ version_tree.reclaim(bounds.retention_dirs, protect)
248
+
249
+
217
250
  def _within(bound: float, step: Callable[[], None]) -> bool:
218
251
  """Run ``step``; True if it completed, False if it raised or exceeded ``bound``.
219
252
 
@@ -40,9 +40,11 @@ from ..seam import (
40
40
  UpdateRequest,
41
41
  seam_socket_path,
42
42
  )
43
+ from . import switch as version_tree
43
44
  from .bounds import load_bounds
44
45
  from .executor import ChildControl, make_executor
45
46
  from .inflight import read_inflight
47
+ from .lifecycle import MIN_FREE_BYTES
46
48
  from .records import (
47
49
  supervisor_record,
48
50
  supervisor_record_path,
@@ -358,10 +360,47 @@ def apply_command(argv: list[str]) -> int:
358
360
  return 0 if outcome.state not in (SeamState.FAILED, SeamState.DOWN) else 1
359
361
 
360
362
 
363
+ def preflight_command(argv: list[str]) -> int:
364
+ """`manta-supervisor preflight <pair>` — may this node take another version?
365
+
366
+ **Local, not a socket verb.** It reads the node's own version tree and applies
367
+ the supervisor's room policy *without writing anything*; it needs no running
368
+ supervisor, so the fleet path can ask before it creates the version dir and the
369
+ refusal is early (NF-12 has teeth). The deploy only asks; the supervisor owns
370
+ the policy.
371
+
372
+ Output mirrors ``apply`` but echoes the pair: ``<state>: <pair>: <reason>``
373
+ (``ok`` / ``refused``), exiting **0** either way — the state is the verdict,
374
+ not the exit code.
375
+ """
376
+ parser = argparse.ArgumentParser(
377
+ prog="manta-supervisor preflight",
378
+ description="Check room for one more version dir before installing it.",
379
+ )
380
+ parser.add_argument("pair", help="the pair to be staged (for the message)")
381
+ parser.add_argument(
382
+ "--min-free-bytes",
383
+ type=int,
384
+ default=None,
385
+ help="override the free-space floor (defaults to the configured floor)",
386
+ )
387
+ args = parser.parse_args(argv)
388
+
389
+ floor = args.min_free_bytes if args.min_free_bytes is not None else MIN_FREE_BYTES
390
+ result = version_tree.preflight(floor)
391
+ state = "ok" if result.ok else "refused"
392
+ # The pair is echoed so an operator running preflight across several pairs
393
+ # can tell which line is which.
394
+ print(f"{state}: {args.pair}: {result.reason}")
395
+ return 0
396
+
397
+
361
398
  def main(argv: list[str] | None = None) -> int:
362
399
  argv = list(sys.argv[1:] if argv is None else argv)
363
400
  if argv and argv[0] == "apply":
364
401
  return apply_command(argv[1:])
402
+ if argv and argv[0] == "preflight":
403
+ return preflight_command(argv[1:])
365
404
 
366
405
  parser = argparse.ArgumentParser(
367
406
  prog="manta-supervisor",
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  import os
25
25
  import shutil
26
26
  import sys
27
- from dataclasses import dataclass
27
+ from dataclasses import dataclass, replace
28
28
  from datetime import datetime, timezone
29
29
  from importlib.metadata import PackageNotFoundError, version
30
30
  from pathlib import Path
@@ -72,19 +72,31 @@ def current_link() -> Path:
72
72
 
73
73
  @dataclass(frozen=True)
74
74
  class Version:
75
- """A version dir's identity: the pair and the interpreter that runs it."""
75
+ """A version dir's identity: the pair, the interpreter, and its size.
76
+
77
+ ``size_bytes`` is recorded once (see ``version_dir_bytes``) so the room check
78
+ is a manifest read, not a recursive walk of the dependency closure on every
79
+ switch. A version dir is immutable (NF-1), so a recorded size stays valid.
80
+ """
76
81
 
77
82
  pair: str
78
83
  python: str
79
84
  created: str
85
+ size_bytes: int | None = None
80
86
 
81
87
  def to_dict(self) -> dict:
82
- return {"pair": self.pair, "python": self.python, "created": self.created}
88
+ data = {"pair": self.pair, "python": self.python, "created": self.created}
89
+ if self.size_bytes is not None:
90
+ data["size_bytes"] = self.size_bytes
91
+ return data
83
92
 
84
93
  @classmethod
85
94
  def from_dict(cls, data: dict) -> Version:
86
95
  return cls(
87
- pair=data["pair"], python=data["python"], created=data.get("created", "")
96
+ pair=data["pair"],
97
+ python=data["python"],
98
+ created=data.get("created", ""),
99
+ size_bytes=data.get("size_bytes"),
88
100
  )
89
101
 
90
102
 
@@ -222,6 +234,29 @@ def list_versions() -> list[Path]:
222
234
  return sorted(dirs, key=created)
223
235
 
224
236
 
237
+ def remove_version(version_dir: Path) -> bool:
238
+ """Remove one version dir and its contents; True if it is gone.
239
+
240
+ Best-effort, like ``reclaim``: a dir that cannot be removed is reported, not
241
+ raised — a stale dir must not fail the switch that is trying to discard it.
242
+ """
243
+ # Never remove the version `current` names, even if a caller asks: the boot
244
+ # path must always resolve to something runnable.
245
+ if os.path.realpath(version_dir) == os.path.realpath(current_link()):
246
+ return False
247
+ try:
248
+ for child in version_dir.iterdir():
249
+ if child.is_dir():
250
+ shutil.rmtree(child)
251
+ else:
252
+ child.unlink()
253
+ version_dir.rmdir()
254
+ return True
255
+ except OSError as exc:
256
+ print(f"retention: could not remove {version_dir}: {exc}", file=sys.stderr)
257
+ return False
258
+
259
+
225
260
  def reclaim(retention_dirs: int, protect: set[Path]) -> list[Path]:
226
261
  """Remove the oldest versions beyond ``retention_dirs``, never a protected one.
227
262
 
@@ -240,33 +275,163 @@ def reclaim(retention_dirs: int, protect: set[Path]) -> list[Path]:
240
275
  break
241
276
  if os.path.realpath(candidate) in protected:
242
277
  continue
243
- try:
244
- for child in candidate.iterdir():
245
- if child.is_dir():
246
- shutil.rmtree(child)
247
- else:
248
- child.unlink()
249
- candidate.rmdir()
278
+ if remove_version(candidate):
250
279
  removed.append(candidate)
251
- except OSError as exc:
252
- # A dir we cannot remove stays: retention is best-effort on a live
253
- # node, and failing the switch over a stale dir would be worse.
254
- print(f"retention: could not reclaim {candidate}: {exc}", file=sys.stderr)
255
280
  return removed
256
281
 
257
282
 
258
- def has_room_for(min_bytes: int) -> bool:
259
- """True when the version tree's filesystem has at least ``min_bytes`` free.
283
+ def _free_bytes() -> int:
284
+ """Free bytes on the version tree's filesystem.
260
285
 
261
- The free-space refusal (NF-12, mechanism §6.2): a node with no room to stage
262
- a full version dir refuses the update rather than half-staging. The caller
263
- passes the candidate's estimated size.
286
+ Checks the nearest existing ancestor: the tree may not exist yet on a fresh
287
+ node, and the capacity is the filesystem's either way.
264
288
  """
265
- root = versions_dir()
266
- # Check the nearest existing ancestor: the tree may not exist yet on a fresh
267
- # node, and the capacity is the filesystem's either way.
268
- probe = root
289
+ probe = versions_dir()
269
290
  while not probe.exists() and probe != probe.parent:
270
291
  probe = probe.parent
271
- free = shutil.disk_usage(probe).free
272
- return free >= min_bytes
292
+ return shutil.disk_usage(probe).free
293
+
294
+
295
+ def version_dir_bytes(version_dir: Path) -> int:
296
+ """The on-disk size of a version dir.
297
+
298
+ Cached in the manifest once measured: a version dir is immutable (NF-1), so
299
+ the size does not change, and a switch on an ARM edge node must not re-walk a
300
+ full dependency closure. The first read measures and writes it back; every
301
+ later read is a manifest lookup. A dir with no readable manifest is measured
302
+ but not cached (there is nowhere to write it).
303
+ """
304
+ manifest = read_manifest(version_dir)
305
+ if manifest is not None and manifest.size_bytes is not None:
306
+ return manifest.size_bytes
307
+
308
+ total = 0
309
+ for path in version_dir.rglob("*"):
310
+ try:
311
+ if path.is_file():
312
+ total += path.stat().st_size
313
+ except OSError:
314
+ # A file that vanished mid-walk is not counted; the size is an
315
+ # estimate for the room check, not a ledger.
316
+ continue
317
+
318
+ if manifest is not None:
319
+ write_json_atomic(
320
+ version_dir / MANIFEST_NAME,
321
+ replace(manifest, size_bytes=total).to_dict(),
322
+ )
323
+ return total
324
+
325
+
326
+ def largest_version_bytes() -> int:
327
+ """The size of the largest existing version dir, or 0 when the tree is empty.
328
+
329
+ This is the best available estimate of "what one more version dir costs":
330
+ the versions on this host are the same package and dependency closure, so the
331
+ largest already-present dir is a floor for the candidate's footprint. Sizes
332
+ come from each dir's manifest cache (see ``version_dir_bytes``), so this is
333
+ manifest reads, not recursive walks.
334
+ """
335
+ sizes = [version_dir_bytes(v) for v in list_versions()]
336
+ return max(sizes) if sizes else 0
337
+
338
+
339
+ def candidate_bytes(min_bytes: int) -> int:
340
+ """The bytes the guard should require free for one more version dir.
341
+
342
+ The design (NF-12, §6.2) is "insufficient free space to stage a FULL version
343
+ dir", so the requirement is the largest existing version dir — known from the
344
+ tree — floored by ``min_bytes`` as a backstop for a fresh or tiny tree.
345
+ """
346
+ return max(largest_version_bytes(), min_bytes)
347
+
348
+
349
+ def has_room_for(min_bytes: int) -> bool:
350
+ """True when the tree's filesystem has room for one more full version dir.
351
+
352
+ The free-space refusal (NF-12, mechanism §6.2): a node with no room to stage
353
+ a full version dir refuses the update rather than half-staging. The
354
+ requirement is ``candidate_bytes(min_bytes)`` — the largest existing version
355
+ dir, floored by ``min_bytes`` — not merely a fixed floor.
356
+ """
357
+ return _free_bytes() >= candidate_bytes(min_bytes)
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class Preflight:
362
+ """The supervisor's answer to "may this node take one more version dir?"."""
363
+
364
+ ok: bool
365
+ reason: str
366
+ free_bytes: int
367
+ required_bytes: int
368
+ versions: int
369
+ reclaimable: int
370
+
371
+ def to_dict(self) -> dict:
372
+ return {
373
+ "ok": self.ok,
374
+ "reason": self.reason,
375
+ "free_bytes": self.free_bytes,
376
+ "required_bytes": self.required_bytes,
377
+ "versions": self.versions,
378
+ "reclaimable": self.reclaimable,
379
+ }
380
+
381
+
382
+ def preflight(min_free_bytes: int) -> Preflight:
383
+ """Whether the node can stage ONE more version dir, read-only.
384
+
385
+ The fleet path calls this **before** it creates anything, so a refusal is
386
+ early and the supervisor owns the policy — the same room requirement the STAGE
387
+ check uses (one policy, one place). Measures and decides; creates nothing.
388
+
389
+ The one refusal is **room**: the filesystem must have ``candidate_bytes`` free
390
+ for the candidate's contents (NF-12). Retention is **not** a refusal — a real
391
+ switch always brings the tree within its bound by reclaiming, so a small tree
392
+ has *more* headroom, not less. ``reclaimable`` is reported for observability;
393
+ it does not affect the verdict.
394
+
395
+ ``reclaimable`` is the count of existing dirs that could be removed while
396
+ protecting the two versions a switch holds: the candidate (not yet created)
397
+ and whatever is current right now.
398
+ """
399
+ free = _free_bytes()
400
+ required = candidate_bytes(min_free_bytes)
401
+ versions = list_versions()
402
+ current = selected()
403
+ # A real switch reclaims with `protect = {candidate, current-now}` — the
404
+ # candidate does not exist yet, so among EXISTING dirs only current is
405
+ # protected. (After the switch, today's current becomes the last good, which
406
+ # is why the two-versions-retained rule holds; but at preflight time the
407
+ # second protected slot is the not-yet-staged candidate, not another existing
408
+ # dir.) So every existing dir but current is reclaimable.
409
+ protected = {os.path.realpath(current)} if current is not None else set()
410
+ reclaimable = sum(1 for v in versions if os.path.realpath(v) not in protected)
411
+ # Retention is NOT a refusal here. A real switch always satisfies its bound:
412
+ # after the candidate lands, `reclaim` removes reclaimable dirs down to the
413
+ # configured keep, and since every existing dir but current is reclaimable the
414
+ # tree always reaches `min(N+1, keep) <= keep`. So retention cannot make an
415
+ # update fail; refusing on it would reject exactly the trees with the most
416
+ # headroom (a small tree under a larger bound). `reclaimable` is reported for
417
+ # observability, not as a gate.
418
+ if free < required:
419
+ return Preflight(
420
+ ok=False,
421
+ reason=(
422
+ f"insufficient free space: {free} < {required} bytes"
423
+ " (one version dir, floored at the minimum)"
424
+ ),
425
+ free_bytes=free,
426
+ required_bytes=required,
427
+ versions=len(versions),
428
+ reclaimable=reclaimable,
429
+ )
430
+ return Preflight(
431
+ ok=True,
432
+ reason="room for one more version dir is available",
433
+ free_bytes=free,
434
+ required_bytes=required,
435
+ versions=len(versions),
436
+ reclaimable=reclaimable,
437
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: manta_node
3
- Version: 0.6b5.dev706
3
+ Version: 0.6b5.dev707
4
4
  Summary: Node side software of the Manta platform. It is responsible for the communication with the Manta Core and the execution of the tasks.
5
5
  Author-email: Hugo Miralles <hugo.miralles@manta-tech.io>, Matthew Thompson <matthew.thompson@manta-tech.io>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -6,7 +6,7 @@ manta_node/task_manager.py,sha256=xRE-yKdUW0FFR8vtX1jDwl1AxTbqVD_JS7QVR62GA00,38
6
6
  manta_node/tasks.py,sha256=o837Hi-KFVJg9LF5iOtE8oD6UuTOfrEPWR-JOdhRGeY,6983
7
7
  manta_node/utils.py,sha256=Hw4Iw2jYQYsfVLVJuBMt-_rMFBeml3nCDE2jiiXc8YM,1155
8
8
  manta_node/cli/__init__.py,sha256=arzO3mXDJ-E2yeeytbZfN7mCswwxdQ43_QjyeL2E5yE,134
9
- manta_node/cli/_version_generated.py,sha256=rTGRherNt_ty7j_gNSOWg1VAt6h2GPc4Yfxo9cEWRio,275
9
+ manta_node/cli/_version_generated.py,sha256=oq-vrYUSYqZEeiOos3Fqzj9GAI7ShLAmLb-7xD8MTio,275
10
10
  manta_node/cli/config_manager.py,sha256=BCju9zqzraviL3KrzRnB-vZQUY__Q5kuEqb7EdB8YdI,4409
11
11
  manta_node/cli/main.py,sha256=dZa6zrypWBvpN8hd4qpxevvlyNFhxKINCg1537NJ36w,4318
12
12
  manta_node/cli/version.py,sha256=3lrnIWpOosszBN-VsCBVLxDgpvCC7IB0aDIKx_3e7hI,3115
@@ -56,14 +56,14 @@ manta_node/supervisor/__init__.py,sha256=kgdoNce1tM4e7jvdxkdmnNJV1L92-NI2cRfYSdm
56
56
  manta_node/supervisor/bounds.py,sha256=MlUfj0tYY-US348JsxYA-Ph5pSpgoOPlRdVTwmrPtZA,3181
57
57
  manta_node/supervisor/executor.py,sha256=a0JafVoTmtbIu87nwUtj08KAC-Q-u0Ajdn_ptyu9yoc,6833
58
58
  manta_node/supervisor/inflight.py,sha256=JOqwXYM928fpcQtYdoK7596eAg28KM70bh85cyQgX3Q,2663
59
- manta_node/supervisor/lifecycle.py,sha256=GDy7d7MHxhabvTRoIkAL-_n0oja-GpmVWYAzOehP_DI,8551
60
- manta_node/supervisor/process.py,sha256=mWd2C4-S8NpupcLp0YbBEYmqQUQbrfMAZi5TF9UQ_cM,17504
59
+ manta_node/supervisor/lifecycle.py,sha256=SA-xJqlxwbLa37fvp0W_YbUZbrRz9vQJDpg6vvuokpI,10303
60
+ manta_node/supervisor/process.py,sha256=N5WvNCJnJR3c6I4uqBPPFqgEdCI9rFulHyGem9Shc-I,19155
61
61
  manta_node/supervisor/records.py,sha256=fUlL1SfmWpGnCbJ9956cuy0H3a7-aEF-B-naqbJiYoU,6405
62
62
  manta_node/supervisor/seam.py,sha256=HomMFTDbc0dZQ9VrfGVhQxjiU7E23-zwyjZnLsnq6dc,8031
63
- manta_node/supervisor/switch.py,sha256=HQcNULUf6JafDGoKIh1mrw-4m9pxvz1hMLUPnwkY6mc,10173
64
- manta_node-0.6b5.dev706.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
65
- manta_node-0.6b5.dev706.dist-info/METADATA,sha256=uSttKoedT1orqAsgzeEQzdiwXS8i8HW1Jdv2ni9q3rE,4231
66
- manta_node-0.6b5.dev706.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
67
- manta_node-0.6b5.dev706.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
68
- manta_node-0.6b5.dev706.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
69
- manta_node-0.6b5.dev706.dist-info/RECORD,,
63
+ manta_node/supervisor/switch.py,sha256=iWLukvuxynK3NU26iIk0qhkwrRPUyJuutV67QA3aLO8,16732
64
+ manta_node-0.6b5.dev707.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
65
+ manta_node-0.6b5.dev707.dist-info/METADATA,sha256=uOmOaIVFbJllDFQMjCOr4zfNlYc-Qq9V4VaBk6uHYDg,4231
66
+ manta_node-0.6b5.dev707.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
67
+ manta_node-0.6b5.dev707.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
68
+ manta_node-0.6b5.dev707.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
69
+ manta_node-0.6b5.dev707.dist-info/RECORD,,