manta-node 0.6b5.dev706__py3-none-any.whl → 0.6b5.dev708__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.
- manta_node/cli/_version_generated.py +3 -3
- manta_node/seam.py +1 -0
- manta_node/supervisor/lifecycle.py +44 -1
- manta_node/supervisor/process.py +39 -0
- manta_node/supervisor/switch.py +211 -26
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/METADATA +1 -1
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/RECORD +11 -11
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/WHEEL +0 -0
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/entry_points.txt +0 -0
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/licenses/LICENSE +0 -0
- {manta_node-0.6b5.dev706.dist-info → manta_node-0.6b5.dev708.dist-info}/top_level.txt +0 -0
|
@@ -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.
|
|
8
|
-
__build_date__ = "2026.09.
|
|
9
|
-
__commit_id__ = "
|
|
7
|
+
__version__ = "2026.09.25.4432e8f0"
|
|
8
|
+
__build_date__ = "2026.09.25"
|
|
9
|
+
__commit_id__ = "4432e8f0"
|
|
10
10
|
__package_version__ = "0.6b5"
|
manta_node/seam.py
CHANGED
|
@@ -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
|
|
@@ -98,7 +99,8 @@ def run_switch(
|
|
|
98
99
|
"""Run the lifecycle for one request and return its terminal outcome.
|
|
99
100
|
|
|
100
101
|
Terminal states: ``REPORTING`` (came back on the new version), ``REVERTED``
|
|
101
|
-
(no return within the window, reverted to the last good), ``
|
|
102
|
+
(no return within the window, reverted to the last good), ``SKIPPED`` (the
|
|
103
|
+
target is already current; nothing to do, no restart), ``FAILED`` (a
|
|
102
104
|
pre-switch step failed; nothing changed), ``DOWN`` (the switch happened and
|
|
103
105
|
the revert itself failed — the node is not running, surfaced honestly).
|
|
104
106
|
|
|
@@ -106,6 +108,15 @@ def run_switch(
|
|
|
106
108
|
lifecycle resolves, so a supervisor restart mid-window can re-evaluate it.
|
|
107
109
|
"""
|
|
108
110
|
bounds = bounds or Bounds()
|
|
111
|
+
|
|
112
|
+
# NO-OP — applying the pair already current changes nothing (NF-5). Return
|
|
113
|
+
# before fetch/verify/stage/commit, so the child is NOT restarted: under B1 a
|
|
114
|
+
# re-apply would otherwise be a full switch and restart the node for no change
|
|
115
|
+
# (the daily loop that never converges). `force` does not bypass this: it is
|
|
116
|
+
# the not-newer override, and a deliberate bounce is a unit restart.
|
|
117
|
+
if version_tree.is_current_pair(request.target):
|
|
118
|
+
return _outcome(request, SeamState.SKIPPED, "target is already current")
|
|
119
|
+
|
|
109
120
|
last_good_dir = version_tree.selected()
|
|
110
121
|
last_good = last_good_dir.name if last_good_dir else None
|
|
111
122
|
candidate_dir = request.target
|
|
@@ -161,11 +172,13 @@ def run_switch(
|
|
|
161
172
|
try:
|
|
162
173
|
if not _within(bounds.switch, lambda: version_tree.commit(target)):
|
|
163
174
|
clear_inflight()
|
|
175
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
164
176
|
return _outcome(
|
|
165
177
|
request, SeamState.FAILED, "switch exceeded its bound (repoint failed)"
|
|
166
178
|
)
|
|
167
179
|
except VersionTreeError as exc:
|
|
168
180
|
clear_inflight()
|
|
181
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
169
182
|
return _outcome(request, SeamState.FAILED, f"switch failed: {exc}")
|
|
170
183
|
|
|
171
184
|
# RESTART onto the committed version, then wait for the return window. A
|
|
@@ -173,6 +186,7 @@ def run_switch(
|
|
|
173
186
|
# the record rather than letting the exception escape.
|
|
174
187
|
if not _within(bounds.switch, restart):
|
|
175
188
|
clear_inflight()
|
|
189
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
176
190
|
return _outcome(request, SeamState.DOWN, "restart failed after the switch")
|
|
177
191
|
|
|
178
192
|
if is_back(record.deadline_epoch):
|
|
@@ -195,6 +209,7 @@ def run_switch(
|
|
|
195
209
|
if last_good is None:
|
|
196
210
|
# Nothing to revert to: surface DOWN rather than claim success.
|
|
197
211
|
clear_inflight()
|
|
212
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
198
213
|
return _outcome(
|
|
199
214
|
request, SeamState.DOWN, "no return and no last-good version to revert to"
|
|
200
215
|
)
|
|
@@ -204,8 +219,13 @@ def run_switch(
|
|
|
204
219
|
raise LifecycleError("restart after revert failed")
|
|
205
220
|
except (VersionTreeError, LifecycleError) as exc:
|
|
206
221
|
clear_inflight()
|
|
222
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
207
223
|
return _outcome(request, SeamState.DOWN, f"revert failed: {exc}")
|
|
208
224
|
clear_inflight()
|
|
225
|
+
# The candidate was superseded the moment the switch did not land: it is
|
|
226
|
+
# reclaimed even though the switch failed, so repeated failing switches do
|
|
227
|
+
# not grow the tree past the bound (NF-12). current/last-good are protected.
|
|
228
|
+
_reclaim_candidate(bounds, target, last_good_dir)
|
|
209
229
|
return _outcome(
|
|
210
230
|
request,
|
|
211
231
|
SeamState.REVERTED,
|
|
@@ -214,6 +234,29 @@ def run_switch(
|
|
|
214
234
|
)
|
|
215
235
|
|
|
216
236
|
|
|
237
|
+
def _reclaim_candidate(
|
|
238
|
+
bounds: Bounds, candidate: Path, last_good_dir: Path | None
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Reclaim after a switch that did NOT land: the candidate is superseded.
|
|
241
|
+
|
|
242
|
+
Called on every FAILED/REVERTED/DOWN path after staging, so repeated failing
|
|
243
|
+
switches do not grow the tree past ``retention_dirs`` (NF-12). The candidate is
|
|
244
|
+
removed first, then ``reclaim`` bounds the rest, protecting ``current`` and the
|
|
245
|
+
last good.
|
|
246
|
+
|
|
247
|
+
Whether the candidate is still current depends on the branch: a failed commit
|
|
248
|
+
leaves it non-current, but a switch whose commit succeeded and whose RESTART
|
|
249
|
+
then failed (or found no last good to revert to) has ``target`` AS ``current``.
|
|
250
|
+
``remove_version`` therefore refuses to delete whatever ``current`` names, and
|
|
251
|
+
that check — not any assumption here — is what keeps the node runnable on those
|
|
252
|
+
DOWN paths. Keep it if the candidate could ever be current.
|
|
253
|
+
"""
|
|
254
|
+
version_tree.remove_version(candidate)
|
|
255
|
+
current = version_tree.selected()
|
|
256
|
+
protect = {p for p in (current, last_good_dir) if p is not None}
|
|
257
|
+
version_tree.reclaim(bounds.retention_dirs, protect)
|
|
258
|
+
|
|
259
|
+
|
|
217
260
|
def _within(bound: float, step: Callable[[], None]) -> bool:
|
|
218
261
|
"""Run ``step``; True if it completed, False if it raised or exceeded ``bound``.
|
|
219
262
|
|
manta_node/supervisor/process.py
CHANGED
|
@@ -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",
|
manta_node/supervisor/switch.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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"],
|
|
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
|
|
|
@@ -203,6 +215,26 @@ def is_pointer_target(target: Path) -> bool:
|
|
|
203
215
|
return link.is_symlink() and Path(os.path.realpath(link)) == target
|
|
204
216
|
|
|
205
217
|
|
|
218
|
+
def is_current_pair(pair: str) -> bool:
|
|
219
|
+
"""True when the pair named by ``pair`` is already the current version.
|
|
220
|
+
|
|
221
|
+
A request to apply the code already running is a no-op (NF-5): under B1 a
|
|
222
|
+
re-apply would otherwise fetch, verify, stage, commit and **restart** the
|
|
223
|
+
child, restarting the node for no change. The check is by pair identity, so it
|
|
224
|
+
holds whether the target names the version dir directly or a set id that
|
|
225
|
+
resolves to it. A node with no valid ``current`` is never "already current".
|
|
226
|
+
"""
|
|
227
|
+
version_dir = selected()
|
|
228
|
+
if version_dir is None:
|
|
229
|
+
return False
|
|
230
|
+
# The dir name and the manifest's pair are the same identity; either naming it
|
|
231
|
+
# is an exact match on what is running.
|
|
232
|
+
if version_dir.name == pair:
|
|
233
|
+
return True
|
|
234
|
+
manifest = read_manifest(version_dir)
|
|
235
|
+
return manifest is not None and manifest.pair == pair
|
|
236
|
+
|
|
237
|
+
|
|
206
238
|
def list_versions() -> list[Path]:
|
|
207
239
|
"""The version dirs, oldest first.
|
|
208
240
|
|
|
@@ -222,6 +254,29 @@ def list_versions() -> list[Path]:
|
|
|
222
254
|
return sorted(dirs, key=created)
|
|
223
255
|
|
|
224
256
|
|
|
257
|
+
def remove_version(version_dir: Path) -> bool:
|
|
258
|
+
"""Remove one version dir and its contents; True if it is gone.
|
|
259
|
+
|
|
260
|
+
Best-effort, like ``reclaim``: a dir that cannot be removed is reported, not
|
|
261
|
+
raised — a stale dir must not fail the switch that is trying to discard it.
|
|
262
|
+
"""
|
|
263
|
+
# Never remove the version `current` names, even if a caller asks: the boot
|
|
264
|
+
# path must always resolve to something runnable.
|
|
265
|
+
if os.path.realpath(version_dir) == os.path.realpath(current_link()):
|
|
266
|
+
return False
|
|
267
|
+
try:
|
|
268
|
+
for child in version_dir.iterdir():
|
|
269
|
+
if child.is_dir():
|
|
270
|
+
shutil.rmtree(child)
|
|
271
|
+
else:
|
|
272
|
+
child.unlink()
|
|
273
|
+
version_dir.rmdir()
|
|
274
|
+
return True
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
print(f"retention: could not remove {version_dir}: {exc}", file=sys.stderr)
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
|
|
225
280
|
def reclaim(retention_dirs: int, protect: set[Path]) -> list[Path]:
|
|
226
281
|
"""Remove the oldest versions beyond ``retention_dirs``, never a protected one.
|
|
227
282
|
|
|
@@ -240,33 +295,163 @@ def reclaim(retention_dirs: int, protect: set[Path]) -> list[Path]:
|
|
|
240
295
|
break
|
|
241
296
|
if os.path.realpath(candidate) in protected:
|
|
242
297
|
continue
|
|
243
|
-
|
|
244
|
-
for child in candidate.iterdir():
|
|
245
|
-
if child.is_dir():
|
|
246
|
-
shutil.rmtree(child)
|
|
247
|
-
else:
|
|
248
|
-
child.unlink()
|
|
249
|
-
candidate.rmdir()
|
|
298
|
+
if remove_version(candidate):
|
|
250
299
|
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
300
|
return removed
|
|
256
301
|
|
|
257
302
|
|
|
258
|
-
def
|
|
259
|
-
"""
|
|
303
|
+
def _free_bytes() -> int:
|
|
304
|
+
"""Free bytes on the version tree's filesystem.
|
|
260
305
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
passes the candidate's estimated size.
|
|
306
|
+
Checks the nearest existing ancestor: the tree may not exist yet on a fresh
|
|
307
|
+
node, and the capacity is the filesystem's either way.
|
|
264
308
|
"""
|
|
265
|
-
|
|
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
|
|
309
|
+
probe = versions_dir()
|
|
269
310
|
while not probe.exists() and probe != probe.parent:
|
|
270
311
|
probe = probe.parent
|
|
271
|
-
|
|
272
|
-
|
|
312
|
+
return shutil.disk_usage(probe).free
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def version_dir_bytes(version_dir: Path) -> int:
|
|
316
|
+
"""The on-disk size of a version dir.
|
|
317
|
+
|
|
318
|
+
Cached in the manifest once measured: a version dir is immutable (NF-1), so
|
|
319
|
+
the size does not change, and a switch on an ARM edge node must not re-walk a
|
|
320
|
+
full dependency closure. The first read measures and writes it back; every
|
|
321
|
+
later read is a manifest lookup. A dir with no readable manifest is measured
|
|
322
|
+
but not cached (there is nowhere to write it).
|
|
323
|
+
"""
|
|
324
|
+
manifest = read_manifest(version_dir)
|
|
325
|
+
if manifest is not None and manifest.size_bytes is not None:
|
|
326
|
+
return manifest.size_bytes
|
|
327
|
+
|
|
328
|
+
total = 0
|
|
329
|
+
for path in version_dir.rglob("*"):
|
|
330
|
+
try:
|
|
331
|
+
if path.is_file():
|
|
332
|
+
total += path.stat().st_size
|
|
333
|
+
except OSError:
|
|
334
|
+
# A file that vanished mid-walk is not counted; the size is an
|
|
335
|
+
# estimate for the room check, not a ledger.
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if manifest is not None:
|
|
339
|
+
write_json_atomic(
|
|
340
|
+
version_dir / MANIFEST_NAME,
|
|
341
|
+
replace(manifest, size_bytes=total).to_dict(),
|
|
342
|
+
)
|
|
343
|
+
return total
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def largest_version_bytes() -> int:
|
|
347
|
+
"""The size of the largest existing version dir, or 0 when the tree is empty.
|
|
348
|
+
|
|
349
|
+
This is the best available estimate of "what one more version dir costs":
|
|
350
|
+
the versions on this host are the same package and dependency closure, so the
|
|
351
|
+
largest already-present dir is a floor for the candidate's footprint. Sizes
|
|
352
|
+
come from each dir's manifest cache (see ``version_dir_bytes``), so this is
|
|
353
|
+
manifest reads, not recursive walks.
|
|
354
|
+
"""
|
|
355
|
+
sizes = [version_dir_bytes(v) for v in list_versions()]
|
|
356
|
+
return max(sizes) if sizes else 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def candidate_bytes(min_bytes: int) -> int:
|
|
360
|
+
"""The bytes the guard should require free for one more version dir.
|
|
361
|
+
|
|
362
|
+
The design (NF-12, §6.2) is "insufficient free space to stage a FULL version
|
|
363
|
+
dir", so the requirement is the largest existing version dir — known from the
|
|
364
|
+
tree — floored by ``min_bytes`` as a backstop for a fresh or tiny tree.
|
|
365
|
+
"""
|
|
366
|
+
return max(largest_version_bytes(), min_bytes)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def has_room_for(min_bytes: int) -> bool:
|
|
370
|
+
"""True when the tree's filesystem has room for one more full version dir.
|
|
371
|
+
|
|
372
|
+
The free-space refusal (NF-12, mechanism §6.2): a node with no room to stage
|
|
373
|
+
a full version dir refuses the update rather than half-staging. The
|
|
374
|
+
requirement is ``candidate_bytes(min_bytes)`` — the largest existing version
|
|
375
|
+
dir, floored by ``min_bytes`` — not merely a fixed floor.
|
|
376
|
+
"""
|
|
377
|
+
return _free_bytes() >= candidate_bytes(min_bytes)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(frozen=True)
|
|
381
|
+
class Preflight:
|
|
382
|
+
"""The supervisor's answer to "may this node take one more version dir?"."""
|
|
383
|
+
|
|
384
|
+
ok: bool
|
|
385
|
+
reason: str
|
|
386
|
+
free_bytes: int
|
|
387
|
+
required_bytes: int
|
|
388
|
+
versions: int
|
|
389
|
+
reclaimable: int
|
|
390
|
+
|
|
391
|
+
def to_dict(self) -> dict:
|
|
392
|
+
return {
|
|
393
|
+
"ok": self.ok,
|
|
394
|
+
"reason": self.reason,
|
|
395
|
+
"free_bytes": self.free_bytes,
|
|
396
|
+
"required_bytes": self.required_bytes,
|
|
397
|
+
"versions": self.versions,
|
|
398
|
+
"reclaimable": self.reclaimable,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def preflight(min_free_bytes: int) -> Preflight:
|
|
403
|
+
"""Whether the node can stage ONE more version dir, read-only.
|
|
404
|
+
|
|
405
|
+
The fleet path calls this **before** it creates anything, so a refusal is
|
|
406
|
+
early and the supervisor owns the policy — the same room requirement the STAGE
|
|
407
|
+
check uses (one policy, one place). Measures and decides; creates nothing.
|
|
408
|
+
|
|
409
|
+
The one refusal is **room**: the filesystem must have ``candidate_bytes`` free
|
|
410
|
+
for the candidate's contents (NF-12). Retention is **not** a refusal — a real
|
|
411
|
+
switch always brings the tree within its bound by reclaiming, so a small tree
|
|
412
|
+
has *more* headroom, not less. ``reclaimable`` is reported for observability;
|
|
413
|
+
it does not affect the verdict.
|
|
414
|
+
|
|
415
|
+
``reclaimable`` is the count of existing dirs that could be removed while
|
|
416
|
+
protecting the two versions a switch holds: the candidate (not yet created)
|
|
417
|
+
and whatever is current right now.
|
|
418
|
+
"""
|
|
419
|
+
free = _free_bytes()
|
|
420
|
+
required = candidate_bytes(min_free_bytes)
|
|
421
|
+
versions = list_versions()
|
|
422
|
+
current = selected()
|
|
423
|
+
# A real switch reclaims with `protect = {candidate, current-now}` — the
|
|
424
|
+
# candidate does not exist yet, so among EXISTING dirs only current is
|
|
425
|
+
# protected. (After the switch, today's current becomes the last good, which
|
|
426
|
+
# is why the two-versions-retained rule holds; but at preflight time the
|
|
427
|
+
# second protected slot is the not-yet-staged candidate, not another existing
|
|
428
|
+
# dir.) So every existing dir but current is reclaimable.
|
|
429
|
+
protected = {os.path.realpath(current)} if current is not None else set()
|
|
430
|
+
reclaimable = sum(1 for v in versions if os.path.realpath(v) not in protected)
|
|
431
|
+
# Retention is NOT a refusal here. A real switch always satisfies its bound:
|
|
432
|
+
# after the candidate lands, `reclaim` removes reclaimable dirs down to the
|
|
433
|
+
# configured keep, and since every existing dir but current is reclaimable the
|
|
434
|
+
# tree always reaches `min(N+1, keep) <= keep`. So retention cannot make an
|
|
435
|
+
# update fail; refusing on it would reject exactly the trees with the most
|
|
436
|
+
# headroom (a small tree under a larger bound). `reclaimable` is reported for
|
|
437
|
+
# observability, not as a gate.
|
|
438
|
+
if free < required:
|
|
439
|
+
return Preflight(
|
|
440
|
+
ok=False,
|
|
441
|
+
reason=(
|
|
442
|
+
f"insufficient free space: {free} < {required} bytes"
|
|
443
|
+
" (one version dir, floored at the minimum)"
|
|
444
|
+
),
|
|
445
|
+
free_bytes=free,
|
|
446
|
+
required_bytes=required,
|
|
447
|
+
versions=len(versions),
|
|
448
|
+
reclaimable=reclaimable,
|
|
449
|
+
)
|
|
450
|
+
return Preflight(
|
|
451
|
+
ok=True,
|
|
452
|
+
reason="room for one more version dir is available",
|
|
453
|
+
free_bytes=free,
|
|
454
|
+
required_bytes=required,
|
|
455
|
+
versions=len(versions),
|
|
456
|
+
reclaimable=reclaimable,
|
|
457
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: manta_node
|
|
3
|
-
Version: 0.6b5.
|
|
3
|
+
Version: 0.6b5.dev708
|
|
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
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
manta_node/__init__.py,sha256=bgGWXH3yQVhNf0v1ZGUBAy5Y51fqb0Y-EaLnocbcino,153
|
|
2
2
|
manta_node/__main__.py,sha256=SjrJyuMg_ZFKV6WOVe3MrtWM8Qmu065dkKZYl4n2YLw,1665
|
|
3
3
|
manta_node/node_orchestrator.py,sha256=nfpNCM8wgnihKhZZ36GIizmyyaOdEOBhdfn7PqqTCrQ,29591
|
|
4
|
-
manta_node/seam.py,sha256=
|
|
4
|
+
manta_node/seam.py,sha256=npu2QdIqnybiz0h4Wb7aE4vPwXJIWTZW3z31FSk6sFk,4771
|
|
5
5
|
manta_node/task_manager.py,sha256=xRE-yKdUW0FFR8vtX1jDwl1AxTbqVD_JS7QVR62GA00,38002
|
|
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=
|
|
9
|
+
manta_node/cli/_version_generated.py,sha256=elmDYSIYkbB-QT_mSGxYNsNsYt6yh9g8VGf2cJ8Mjqs,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=
|
|
60
|
-
manta_node/supervisor/process.py,sha256=
|
|
59
|
+
manta_node/supervisor/lifecycle.py,sha256=HARQv3wYyK-ZjcBac-ixQMVlkjHvNtIpRdSMm2lNHCQ,10915
|
|
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=
|
|
64
|
-
manta_node-0.6b5.
|
|
65
|
-
manta_node-0.6b5.
|
|
66
|
-
manta_node-0.6b5.
|
|
67
|
-
manta_node-0.6b5.
|
|
68
|
-
manta_node-0.6b5.
|
|
69
|
-
manta_node-0.6b5.
|
|
63
|
+
manta_node/supervisor/switch.py,sha256=hFCWhZDoWN9KN0zkNepsQfkypDhhlkBKmCOltCqlb3w,17614
|
|
64
|
+
manta_node-0.6b5.dev708.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
|
|
65
|
+
manta_node-0.6b5.dev708.dist-info/METADATA,sha256=oFmV3hh5ae8-ezPYCE94YMmqzabs651BUKV_cC4WI6A,4231
|
|
66
|
+
manta_node-0.6b5.dev708.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
67
|
+
manta_node-0.6b5.dev708.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
|
|
68
|
+
manta_node-0.6b5.dev708.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
|
|
69
|
+
manta_node-0.6b5.dev708.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|