manta-node 0.6b5.dev762__py3-none-any.whl → 0.6b5.dev768__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 +2 -2
- manta_node/infrastructure/mqtt/command_handler.py +162 -23
- manta_node/node_orchestrator.py +10 -2
- manta_node/seam.py +41 -6
- manta_node/supervisor/executor.py +44 -14
- manta_node/supervisor/inflight.py +57 -0
- manta_node/supervisor/lifecycle.py +3 -3
- manta_node/supervisor/process.py +77 -47
- manta_node/supervisor/seam.py +40 -1
- manta_node/supervisor/sequence.py +46 -0
- manta_node/supervisor/switch.py +157 -55
- manta_node/update_path.py +32 -0
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.dist-info}/METADATA +2 -2
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.dist-info}/RECORD +18 -16
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.dist-info}/WHEEL +0 -0
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.dist-info}/entry_points.txt +0 -0
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.dist-info}/licenses/LICENSE +0 -0
- {manta_node-0.6b5.dev762.dist-info → manta_node-0.6b5.dev768.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.10.07.
|
|
7
|
+
__version__ = "2026.10.07.bfaeeedd"
|
|
8
8
|
__build_date__ = "2026.10.07"
|
|
9
|
-
__commit_id__ = "
|
|
9
|
+
__commit_id__ = "bfaeeedd"
|
|
10
10
|
__package_version__ = "0.6b5" # release-series base, moved by scripts/bump_version.py
|
|
@@ -2,7 +2,9 @@ import asyncio
|
|
|
2
2
|
import base64
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
7
|
+
import time
|
|
6
8
|
from collections.abc import Awaitable, Callable
|
|
7
9
|
from datetime import datetime, timezone
|
|
8
10
|
from logging import getLogger
|
|
@@ -11,7 +13,13 @@ from typing import cast
|
|
|
11
13
|
|
|
12
14
|
import aiomqtt
|
|
13
15
|
from manta_common.base_mqtt import MqttBase
|
|
14
|
-
from manta_common.build.common.mqtt import
|
|
16
|
+
from manta_common.build.common.mqtt import (
|
|
17
|
+
MqMetricsSnapshot,
|
|
18
|
+
MqNodeUpdate,
|
|
19
|
+
MqNodeUpdateStatus,
|
|
20
|
+
MqTaskUpdate,
|
|
21
|
+
NodeUpdateState,
|
|
22
|
+
)
|
|
15
23
|
from manta_common.build.common.tasks import MqttTask, TaskStatus, TaskUpdate
|
|
16
24
|
from manta_common.conversions import ID, b2x
|
|
17
25
|
from manta_common.errors import (
|
|
@@ -24,13 +32,17 @@ from manta_common.retry import RetryPolicy
|
|
|
24
32
|
from manta_common.traces import Tracer
|
|
25
33
|
|
|
26
34
|
from ...seam import (
|
|
35
|
+
UPDATING_ENV,
|
|
27
36
|
SeamClient,
|
|
28
37
|
SeamError,
|
|
38
|
+
SeamState,
|
|
29
39
|
SwitchOutcome,
|
|
30
40
|
UpdateRequest,
|
|
41
|
+
is_safe_version_name,
|
|
31
42
|
)
|
|
32
43
|
from ...task_manager import LOG_FLUSH_INTERVAL, LogStream, TaskManager
|
|
33
44
|
from ...tasks import TaskProgression
|
|
45
|
+
from ...update_path import is_host_owned
|
|
34
46
|
from ..filesystem.dataset_manager import DatasetManager
|
|
35
47
|
from ..grpc.client import NodeClient
|
|
36
48
|
from ..metrics.collector import Collector
|
|
@@ -42,6 +54,19 @@ TIMEOUT = 1 # s
|
|
|
42
54
|
MAX_LOG_UPLOAD_ATTEMPTS = 5
|
|
43
55
|
MAX_MQTT_CONNECT_FAILURES = 3
|
|
44
56
|
|
|
57
|
+
# A restarted node replays the request that restarted it until the supervisor
|
|
58
|
+
# answers with the terminal outcome. The supervisor holds the request in flight
|
|
59
|
+
# for the return window (~40 s by default) before it can answer, so the replay
|
|
60
|
+
# polls across that window with headroom; past it the platform's own deadline
|
|
61
|
+
# settles the request.
|
|
62
|
+
REPLAY_POLL_SECONDS = 2.0
|
|
63
|
+
REPLAY_TIMEOUT_SECONDS = 120.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _node_update_state(state) -> NodeUpdateState:
|
|
67
|
+
"""Map a seam state to the wire enum; they mirror 1:1 (manta-common#291)."""
|
|
68
|
+
return NodeUpdateState[state.name]
|
|
69
|
+
|
|
45
70
|
|
|
46
71
|
def _is_permanent_upload_rejection(exc: Exception) -> bool:
|
|
47
72
|
"""Whether the gRPC status carries a rejection the retry policy will not retry."""
|
|
@@ -312,14 +337,34 @@ class CommandHandler(Collector, MqttBase):
|
|
|
312
337
|
"""
|
|
313
338
|
self.tracer.debug("Starting background task checker")
|
|
314
339
|
checker = asyncio.create_task(self._ping_loop())
|
|
340
|
+
replayer = self._start_update_replay()
|
|
315
341
|
try:
|
|
316
342
|
await MqttBase.main(self)
|
|
317
343
|
finally:
|
|
318
344
|
checker.cancel()
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
345
|
+
if replayer is not None:
|
|
346
|
+
replayer.cancel()
|
|
347
|
+
for task in (checker, replayer):
|
|
348
|
+
if task is None:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
await task
|
|
352
|
+
except asyncio.CancelledError:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
def _start_update_replay(self) -> asyncio.Task | None:
|
|
356
|
+
"""Start the replay of the update that restarted this node, if any.
|
|
357
|
+
|
|
358
|
+
The supervisor set ``MANTA_NODE_UPDATING`` on this boot when it restarted
|
|
359
|
+
the child for an in-flight update; the node asks for that request's
|
|
360
|
+
outcome once its MQTT loop is up (NF-8). No supervisor, or no such boot,
|
|
361
|
+
means no replay.
|
|
362
|
+
"""
|
|
363
|
+
request_id = os.environ.get(UPDATING_ENV)
|
|
364
|
+
if not request_id or self._seam_client is None:
|
|
365
|
+
return None
|
|
366
|
+
self.tracer.info(f"Replaying in-flight update {request_id} for its outcome")
|
|
367
|
+
return asyncio.create_task(self.report_inflight_update(request_id))
|
|
323
368
|
|
|
324
369
|
async def loop_tasks(self):
|
|
325
370
|
"""
|
|
@@ -703,35 +748,129 @@ class CommandHandler(Collector, MqttBase):
|
|
|
703
748
|
async def update_node(self, payload: bytes):
|
|
704
749
|
"""Handle an `update_node` command from the Manager (the Manager path).
|
|
705
750
|
|
|
706
|
-
The Manager declares a
|
|
707
|
-
the
|
|
708
|
-
|
|
709
|
-
§6.2)
|
|
710
|
-
|
|
751
|
+
The Manager declares a target `manta-node` version in an `MqNodeUpdate`
|
|
752
|
+
(manta-common#291); the node validates it and relays it to its supervisor
|
|
753
|
+
over the local seam. The node computes no version ordering (mechanism
|
|
754
|
+
§6.2): it passes the declared sequence through and re-checks only
|
|
755
|
+
equality and staleness. With no supervisor the relay is inert, and the
|
|
756
|
+
node reports that rather than applying anything itself.
|
|
757
|
+
|
|
758
|
+
A host-owned node refuses the command before relaying (ADR-0042 D5) and
|
|
759
|
+
reports the refusal, so the platform's one-path rule holds even if a
|
|
760
|
+
command reaches it.
|
|
711
761
|
"""
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
762
|
+
request = self._parse_update(payload)
|
|
763
|
+
# One update path per node (ADR-0042 D5). The platform never sends to a
|
|
764
|
+
# host-owned node (it fails closed on the reported path); if one arrives
|
|
765
|
+
# anyway, refuse it here and report the refusal rather than applying it.
|
|
766
|
+
if is_host_owned():
|
|
767
|
+
await self.publish_update_status(
|
|
768
|
+
SwitchOutcome(
|
|
769
|
+
request_id=request.request_id,
|
|
770
|
+
state=SeamState.REFUSED,
|
|
771
|
+
reason="this node is host-updated; the platform does not update it",
|
|
772
|
+
)
|
|
719
773
|
)
|
|
720
|
-
|
|
721
|
-
# A malformed payload — invalid JSON, missing request_id/target, or a
|
|
722
|
-
# wrong shape — is reported the same way, not left to escape as a
|
|
723
|
-
# bare KeyError past the dispatch's MantaError handling.
|
|
724
|
-
raise MantaMQTTError(f"Malformed update_node payload: {exc}") from exc
|
|
774
|
+
return
|
|
725
775
|
outcome = await self.relay_update(request)
|
|
726
776
|
if outcome is None:
|
|
727
777
|
raise MantaNodeError(
|
|
728
778
|
"update_node received but no supervisor serves the seam"
|
|
729
779
|
)
|
|
780
|
+
await self.publish_update_status(outcome)
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def _parse_update(payload: bytes) -> UpdateRequest:
|
|
784
|
+
"""Validate an `MqNodeUpdate` and turn it into the seam's request.
|
|
785
|
+
|
|
786
|
+
A malformed payload — undecodable, or missing the version or the request
|
|
787
|
+
id — is a `MantaMQTTError`, not a bare decode error escaping the
|
|
788
|
+
dispatch's MantaError handling.
|
|
789
|
+
"""
|
|
790
|
+
try:
|
|
791
|
+
command = MqNodeUpdate().parse(payload)
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
raise MantaMQTTError(f"Malformed update_node payload: {exc}") from exc
|
|
794
|
+
if not command.node_package_version or not command.request_id:
|
|
795
|
+
raise MantaMQTTError(
|
|
796
|
+
"Malformed update_node payload: node_package_version and"
|
|
797
|
+
" request_id are both required"
|
|
798
|
+
)
|
|
799
|
+
if not is_safe_version_name(command.node_package_version):
|
|
800
|
+
# The declared version names a directory in the supervisor's version
|
|
801
|
+
# tree; a separator, `..` or an absolute path must never reach it.
|
|
802
|
+
raise MantaMQTTError(
|
|
803
|
+
"Malformed update_node payload: node_package_version is not a"
|
|
804
|
+
" valid version name"
|
|
805
|
+
)
|
|
806
|
+
return UpdateRequest(
|
|
807
|
+
request_id=command.request_id,
|
|
808
|
+
target=command.node_package_version,
|
|
809
|
+
force=bool(command.force),
|
|
810
|
+
declared_sequence=command.declared_sequence,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
async def publish_update_status(self, outcome: SwitchOutcome) -> None:
|
|
814
|
+
"""Publish an update outcome on `manager/update_node_status`.
|
|
815
|
+
|
|
816
|
+
The payload carries the node token, as every node → manager message does
|
|
817
|
+
(the manager validates it). QoS 1: the outcome is worth a redelivery
|
|
818
|
+
attempt, and the node may have just restarted.
|
|
819
|
+
"""
|
|
730
820
|
await self.publish_message(
|
|
731
821
|
"manager/update_node_status",
|
|
732
|
-
|
|
822
|
+
MqNodeUpdateStatus(
|
|
823
|
+
token=self.node_client.secured_token,
|
|
824
|
+
request_id=outcome.request_id,
|
|
825
|
+
state=_node_update_state(outcome.state),
|
|
826
|
+
reason=outcome.reason,
|
|
827
|
+
),
|
|
828
|
+
qos=1,
|
|
733
829
|
)
|
|
734
830
|
|
|
831
|
+
async def report_inflight_update(self, request_id: str) -> None:
|
|
832
|
+
"""Report the outcome of the update that restarted this node (NF-8).
|
|
833
|
+
|
|
834
|
+
The switch restarted the child, so the synchronous seam reply never
|
|
835
|
+
reached the process that sent the request. The supervisor named the
|
|
836
|
+
request in `MANTA_NODE_UPDATING` on this boot; the node replays it over
|
|
837
|
+
the seam and publishes the terminal outcome. While the switch is still in
|
|
838
|
+
flight the supervisor answers REFUSED, so the replay polls.
|
|
839
|
+
|
|
840
|
+
If no outcome ever arrives (the supervisor no longer holds one), nothing
|
|
841
|
+
is published: the platform's deadline settles the request. That is the
|
|
842
|
+
accepted behaviour, stated in the PR.
|
|
843
|
+
"""
|
|
844
|
+
if self._seam_client is None:
|
|
845
|
+
return
|
|
846
|
+
deadline = time.monotonic() + REPLAY_TIMEOUT_SECONDS
|
|
847
|
+
while True:
|
|
848
|
+
outcome = await self._replay_once(request_id)
|
|
849
|
+
if outcome is not None and outcome.state is not SeamState.REFUSED:
|
|
850
|
+
self.tracer.info(
|
|
851
|
+
f"Reporting outcome for in-flight update {request_id}:"
|
|
852
|
+
f" {outcome.state.value}"
|
|
853
|
+
)
|
|
854
|
+
await self.publish_update_status(outcome)
|
|
855
|
+
return
|
|
856
|
+
if time.monotonic() >= deadline:
|
|
857
|
+
self.tracer.warning(
|
|
858
|
+
f"No outcome for in-flight update {request_id} within"
|
|
859
|
+
f" {REPLAY_TIMEOUT_SECONDS}s; the platform's deadline settles it"
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
await asyncio.sleep(REPLAY_POLL_SECONDS)
|
|
863
|
+
|
|
864
|
+
async def _replay_once(self, request_id: str) -> SwitchOutcome | None:
|
|
865
|
+
"""Ask the supervisor once for a request's outcome; None if unreachable."""
|
|
866
|
+
try:
|
|
867
|
+
return await self._seam_client.request_update(
|
|
868
|
+
UpdateRequest(request_id=request_id, target="", replay=True)
|
|
869
|
+
)
|
|
870
|
+
except SeamError as exc:
|
|
871
|
+
self.tracer.warning(f"Update outcome replay failed: {exc}")
|
|
872
|
+
return None
|
|
873
|
+
|
|
735
874
|
async def on_task_setup_failed(self, task: MqttTask, exc: Exception):
|
|
736
875
|
"""
|
|
737
876
|
Publish a FAILED task update when task setup fails.
|
manta_node/node_orchestrator.py
CHANGED
|
@@ -16,6 +16,7 @@ from manta_common.build.common.informations import (
|
|
|
16
16
|
NodeOverview,
|
|
17
17
|
NodeStatus,
|
|
18
18
|
NodeStatusEnum,
|
|
19
|
+
NodeUpdatePath,
|
|
19
20
|
)
|
|
20
21
|
from manta_common.build.common.system import Empty
|
|
21
22
|
from manta_common.conversions import HEX_SIZE, ID, oid
|
|
@@ -38,9 +39,10 @@ from .infrastructure.mqtt.command_handler import CommandHandler
|
|
|
38
39
|
from .infrastructure.process.executor import ProcessExecutor
|
|
39
40
|
from .infrastructure.security.auth_agent import AuthAgent
|
|
40
41
|
from .infrastructure.security.token_provider import InMemoryTokenProvider
|
|
41
|
-
from .seam import seam_socket_path
|
|
42
|
+
from .seam import UPDATING_ENV, seam_socket_path
|
|
42
43
|
from .supervisor.seam import SocketSeamClient
|
|
43
44
|
from .task_manager import TaskManager
|
|
45
|
+
from .update_path import update_path as node_update_path
|
|
44
46
|
|
|
45
47
|
__all__ = ["Node", "NodeFatalError"]
|
|
46
48
|
|
|
@@ -514,6 +516,7 @@ class Node:
|
|
|
514
516
|
await self.check_manager_availability()
|
|
515
517
|
try:
|
|
516
518
|
version_info = get_full_version_info()
|
|
519
|
+
path = node_update_path()
|
|
517
520
|
identification = NodeOverview(
|
|
518
521
|
self.metrics_collector.get_platform_info(
|
|
519
522
|
_execution_mode_from_config(self.config)
|
|
@@ -522,6 +525,11 @@ class Node:
|
|
|
522
525
|
node_package_version=version_info["package_version"],
|
|
523
526
|
node_build_version=version_info["version"],
|
|
524
527
|
common_core_version=get_common_core_version(),
|
|
528
|
+
# Set at install; unknown is UNSPECIFIED, which the platform
|
|
529
|
+
# fails closed on (never sends an update).
|
|
530
|
+
update_path=(
|
|
531
|
+
NodeUpdatePath[path.upper()] if path else NodeUpdatePath.UNSPECIFIED
|
|
532
|
+
),
|
|
525
533
|
)
|
|
526
534
|
registration = await self.node_client.register_node(identification)
|
|
527
535
|
|
|
@@ -691,7 +699,7 @@ class Node:
|
|
|
691
699
|
# attributed to the update, not read as a fleet disconnect
|
|
692
700
|
# (NF-8). The manager correlates the id; the node does not
|
|
693
701
|
# treat this as an unexpected loss.
|
|
694
|
-
updating = os.environ.get(
|
|
702
|
+
updating = os.environ.get(UPDATING_ENV)
|
|
695
703
|
if updating:
|
|
696
704
|
self.tracer.info(
|
|
697
705
|
f"Restarting for in-flight update {updating};"
|
manta_node/seam.py
CHANGED
|
@@ -13,8 +13,8 @@ reason. Keeping it total (messages and the abstract client) means both sides can
|
|
|
13
13
|
depend on it without a cycle.
|
|
14
14
|
|
|
15
15
|
**Contract** (mechanism §6.4). The request carries `request_id` (idempotency),
|
|
16
|
-
the `target` (
|
|
17
|
-
`declared_sequence` the Manager passed through. The supervisor answers with a
|
|
16
|
+
the `target` (the `manta-node` version — the node computes no ordering), `force`,
|
|
17
|
+
and the `declared_sequence` the Manager passed through. The supervisor answers with a
|
|
18
18
|
`state` and a `reason`. The switch restarts the child, so the outcome across a
|
|
19
19
|
switch is observed **asynchronously** after the node comes back (NF-8); the seam
|
|
20
20
|
carries initiation synchronously and the immediate outcome.
|
|
@@ -23,6 +23,7 @@ carries initiation synchronously and the immediate outcome.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import json
|
|
26
|
+
import re
|
|
26
27
|
from dataclasses import dataclass, field
|
|
27
28
|
from enum import Enum
|
|
28
29
|
from pathlib import Path
|
|
@@ -32,6 +33,35 @@ from typing import Protocol, runtime_checkable
|
|
|
32
33
|
# 0600 on the socket and its parent. It is local and same-user only (NF-9/NF-10).
|
|
33
34
|
SEAM_SOCKET_NAME = "supervisor.sock"
|
|
34
35
|
|
|
36
|
+
# The supervisor sets this on the child's env when it restarts the child for an
|
|
37
|
+
# in-flight update. Its value is the request id; the restarted node replays that
|
|
38
|
+
# request over the seam to learn the outcome the restart cut off (NF-8/NF-6: the
|
|
39
|
+
# node never reads the supervisor's in-flight record; it asks).
|
|
40
|
+
UPDATING_ENV = "MANTA_NODE_UPDATING"
|
|
41
|
+
|
|
42
|
+
# A replay names the request it is asking about rather than a target. The
|
|
43
|
+
# supervisor answers a replay from the completed outcome it holds, never by
|
|
44
|
+
# running the lifecycle: a replay carries no target a switch could apply.
|
|
45
|
+
REPLAY_IN_FLIGHT_REASON = "the request is still in flight"
|
|
46
|
+
REPLAY_UNKNOWN_REASON = "no outcome for this request"
|
|
47
|
+
|
|
48
|
+
# A target names a directory in the version tree. It arrives over the network
|
|
49
|
+
# (the Manager's declared version), so it must be ONE safe path component: no
|
|
50
|
+
# separator, no `..`, no absolute path, or `versions_dir() / target` would
|
|
51
|
+
# resolve outside the tree. PEP 440 versions and the legacy pair id
|
|
52
|
+
# (`manta-node@X+manta-common-core@Y`) both fit this positive set.
|
|
53
|
+
_VERSION_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+@-]*$")
|
|
54
|
+
MAX_VERSION_NAME = 200
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_safe_version_name(name: str) -> bool:
|
|
58
|
+
"""Whether ``name`` is safe as a single version-tree directory component."""
|
|
59
|
+
return (
|
|
60
|
+
isinstance(name, str)
|
|
61
|
+
and 0 < len(name) <= MAX_VERSION_NAME
|
|
62
|
+
and _VERSION_NAME_RE.match(name) is not None
|
|
63
|
+
)
|
|
64
|
+
|
|
35
65
|
|
|
36
66
|
def seam_socket_path() -> Path:
|
|
37
67
|
"""The Unix-socket path for the seam (in the node's state directory)."""
|
|
@@ -60,16 +90,19 @@ class SeamState(str, Enum):
|
|
|
60
90
|
class UpdateRequest:
|
|
61
91
|
"""A request from the running node to its supervisor.
|
|
62
92
|
|
|
63
|
-
``target`` is the
|
|
64
|
-
no ordering, it passes the declaration through. ``declared_sequence``
|
|
65
|
-
Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of
|
|
66
|
-
request already in flight or completed is a no-op (NF-5).
|
|
93
|
+
``target`` is the ``manta-node`` version the Manager declared; the node
|
|
94
|
+
computes no ordering, it passes the declaration through. ``declared_sequence``
|
|
95
|
+
is the Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of
|
|
96
|
+
a request already in flight or completed is a no-op (NF-5). ``replay`` marks a
|
|
97
|
+
restarted node asking for the outcome of the request that restarted it: the
|
|
98
|
+
supervisor answers from the completed outcome and never runs the lifecycle.
|
|
67
99
|
"""
|
|
68
100
|
|
|
69
101
|
request_id: str
|
|
70
102
|
target: str
|
|
71
103
|
force: bool = False
|
|
72
104
|
declared_sequence: int | None = None
|
|
105
|
+
replay: bool = False
|
|
73
106
|
|
|
74
107
|
def to_bytes(self) -> bytes:
|
|
75
108
|
return json.dumps(
|
|
@@ -78,6 +111,7 @@ class UpdateRequest:
|
|
|
78
111
|
"target": self.target,
|
|
79
112
|
"force": self.force,
|
|
80
113
|
"declared_sequence": self.declared_sequence,
|
|
114
|
+
"replay": self.replay,
|
|
81
115
|
}
|
|
82
116
|
).encode()
|
|
83
117
|
|
|
@@ -89,6 +123,7 @@ class UpdateRequest:
|
|
|
89
123
|
target=data["target"],
|
|
90
124
|
force=bool(data.get("force", False)),
|
|
91
125
|
declared_sequence=data.get("declared_sequence"),
|
|
126
|
+
replay=bool(data.get("replay", False)),
|
|
92
127
|
)
|
|
93
128
|
|
|
94
129
|
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
Mechanism §6.4's "executor" side. ``run_switch`` (``lifecycle.py``) is the state
|
|
4
4
|
machine; this module supplies its four verbs as concrete system actions:
|
|
5
5
|
|
|
6
|
-
- **fetch** —
|
|
7
|
-
only; ADR-0042 D2 says PyPI by default).
|
|
8
|
-
|
|
9
|
-
the
|
|
10
|
-
and the *record* honest without duplicating the installer.
|
|
6
|
+
- **fetch** — install the declared manta-node version into a version dir (NF-10, pull
|
|
7
|
+
only; ADR-0042 D2 says PyPI by default). The fetch either reuses a version dir
|
|
8
|
+
the fleet apply already materialised, or installs the declared version itself and
|
|
9
|
+
lets the wheel's pin pull the tested common-core (#509).
|
|
11
10
|
- **verify** — NF-2: import the candidate entrypoint and call the reporting path in
|
|
12
11
|
the candidate's own environment. The full closure check is the same subprocess
|
|
13
12
|
check; a failure leaves the node on its current version.
|
|
@@ -30,7 +29,13 @@ import time
|
|
|
30
29
|
from collections.abc import Awaitable, Callable
|
|
31
30
|
from dataclasses import dataclass
|
|
32
31
|
|
|
33
|
-
from ..seam import
|
|
32
|
+
from ..seam import (
|
|
33
|
+
SeamState,
|
|
34
|
+
SwitchOutcome,
|
|
35
|
+
UpdateRequest,
|
|
36
|
+
is_safe_version_name,
|
|
37
|
+
)
|
|
38
|
+
from . import sequence
|
|
34
39
|
from . import switch as version_tree
|
|
35
40
|
from .bounds import Bounds, load_bounds
|
|
36
41
|
from .lifecycle import run_switch
|
|
@@ -90,7 +95,7 @@ def make_executor(
|
|
|
90
95
|
*,
|
|
91
96
|
bounds: Bounds | None = None,
|
|
92
97
|
verify: Callable[[str, str | None], None] = verify_candidate,
|
|
93
|
-
|
|
98
|
+
install: Callable[[str], str] = version_tree.install_version,
|
|
94
99
|
) -> Callable[[UpdateRequest], Awaitable[SwitchOutcome]]:
|
|
95
100
|
"""Build the seam handler: a request in, the lifecycle's outcome out.
|
|
96
101
|
|
|
@@ -110,16 +115,15 @@ def make_executor(
|
|
|
110
115
|
switching = threading.Lock()
|
|
111
116
|
|
|
112
117
|
def fetch(target: str) -> str:
|
|
113
|
-
# Pull-only (NF-10).
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# the version dir is (re)materialised at STAGE.
|
|
118
|
+
# Pull-only (NF-10). A version dir the fleet path already materialised
|
|
119
|
+
# (with its interpreter) is reused, so verification runs in the
|
|
120
|
+
# candidate's own environment. Otherwise INSTALL it: the Manager path
|
|
121
|
+
# installs the declared manta-node version into a fresh version dir and
|
|
122
|
+
# lets the wheel's pin pull the common-core it was tested with (#509).
|
|
119
123
|
manifest = version_tree.read_manifest(version_tree.versions_dir() / target)
|
|
120
124
|
if manifest is not None:
|
|
121
125
|
return manifest.python
|
|
122
|
-
return
|
|
126
|
+
return install(target)
|
|
123
127
|
|
|
124
128
|
def restart() -> None:
|
|
125
129
|
control.restart()
|
|
@@ -134,6 +138,17 @@ def make_executor(
|
|
|
134
138
|
return control.is_alive()
|
|
135
139
|
|
|
136
140
|
async def handler(request: UpdateRequest) -> SwitchOutcome:
|
|
141
|
+
# The target names a directory in the version tree and arrives over the
|
|
142
|
+
# network, so refuse anything that is not one safe component before the
|
|
143
|
+
# path is ever built (a replay never reaches here: the seam server
|
|
144
|
+
# answers it from the outcome). Defence in depth behind the MQTT
|
|
145
|
+
# handler's own check.
|
|
146
|
+
if not is_safe_version_name(request.target):
|
|
147
|
+
return SwitchOutcome(
|
|
148
|
+
request_id=request.request_id,
|
|
149
|
+
state=SeamState.REFUSED,
|
|
150
|
+
reason="target is not a valid version name",
|
|
151
|
+
)
|
|
137
152
|
# Refuse a second switch while one is in flight (NF-5): a switch restarts
|
|
138
153
|
# the child and moves `current`, so concurrency there is a race, not a
|
|
139
154
|
# queue. `acquire(blocking=False)` makes the check atomic.
|
|
@@ -144,6 +159,21 @@ def make_executor(
|
|
|
144
159
|
reason="a switch is already in flight",
|
|
145
160
|
)
|
|
146
161
|
try:
|
|
162
|
+
# STALE — refuse a request whose declared sequence is lower than the
|
|
163
|
+
# last accepted one (ADR-0042 D4). The sequence orders REQUESTS, not
|
|
164
|
+
# versions; the node computes no version ordering. A replay carries
|
|
165
|
+
# no sequence and is answered by the seam server before reaching here.
|
|
166
|
+
if request.declared_sequence is not None and not sequence.accept_sequence(
|
|
167
|
+
request.declared_sequence
|
|
168
|
+
):
|
|
169
|
+
return SwitchOutcome(
|
|
170
|
+
request_id=request.request_id,
|
|
171
|
+
state=SeamState.REFUSED,
|
|
172
|
+
reason=(
|
|
173
|
+
"stale request: declared_sequence"
|
|
174
|
+
f" {request.declared_sequence} is lower than the last accepted"
|
|
175
|
+
),
|
|
176
|
+
)
|
|
147
177
|
# The lifecycle is synchronous (it blocks through the return window);
|
|
148
178
|
# run it in a worker thread so it does not block the seam's event
|
|
149
179
|
# loop, which is serving the socket the request arrived on.
|
|
@@ -21,12 +21,22 @@ from pathlib import Path
|
|
|
21
21
|
from .records import read_json, write_json_atomic
|
|
22
22
|
|
|
23
23
|
INFLIGHT_NAME = "inflight.json"
|
|
24
|
+
# The last terminal outcome, written when a switch resolves and read when a
|
|
25
|
+
# restarted node replays its request. It outlives the in-flight record and a
|
|
26
|
+
# supervisor restart, so a replay is answered from the fact rather than from
|
|
27
|
+
# memory alone (NF-1/NF-8). The node never reads this file: it asks the
|
|
28
|
+
# supervisor over the seam (ADR-0041 D11).
|
|
29
|
+
OUTCOME_NAME = "update_outcome.json"
|
|
24
30
|
|
|
25
31
|
|
|
26
32
|
def inflight_path() -> Path:
|
|
27
33
|
return Path.home() / ".manta" / "nodes" / INFLIGHT_NAME
|
|
28
34
|
|
|
29
35
|
|
|
36
|
+
def outcome_path() -> Path:
|
|
37
|
+
return Path.home() / ".manta" / "nodes" / OUTCOME_NAME
|
|
38
|
+
|
|
39
|
+
|
|
30
40
|
@dataclass(frozen=True)
|
|
31
41
|
class InFlight:
|
|
32
42
|
"""The durable record of a switch in progress."""
|
|
@@ -80,3 +90,50 @@ def clear_inflight(path: Path | None = None) -> None:
|
|
|
80
90
|
def deadline_from_now(window: float) -> float:
|
|
81
91
|
"""The deadline for a window that starts now (the window is about the switch)."""
|
|
82
92
|
return time.time() + window
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class UpdateOutcome:
|
|
97
|
+
"""The terminal outcome of one update request, durable for a replay.
|
|
98
|
+
|
|
99
|
+
``state`` is a ``SeamState`` value string; the seam server converts it back
|
|
100
|
+
when it answers. Only a terminal (non-REFUSED) outcome is stored, so a replay
|
|
101
|
+
cannot read back a transient refusal as a verdict.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
request_id: str
|
|
105
|
+
state: str
|
|
106
|
+
reason: str
|
|
107
|
+
detail: dict
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"request_id": self.request_id,
|
|
112
|
+
"state": self.state,
|
|
113
|
+
"reason": self.reason,
|
|
114
|
+
"detail": self.detail,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, data: dict) -> UpdateOutcome:
|
|
119
|
+
return cls(
|
|
120
|
+
request_id=data["request_id"],
|
|
121
|
+
state=data["state"],
|
|
122
|
+
reason=data.get("reason", ""),
|
|
123
|
+
detail=data.get("detail", {}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_outcome(outcome: UpdateOutcome, path: Path | None = None) -> None:
|
|
128
|
+
write_json_atomic(path or outcome_path(), outcome.to_dict())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_outcome(path: Path | None = None) -> UpdateOutcome | None:
|
|
132
|
+
"""The last terminal outcome, or None when there is none/one is malformed."""
|
|
133
|
+
data = read_json(path or outcome_path())
|
|
134
|
+
if not data:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
return UpdateOutcome.from_dict(data)
|
|
138
|
+
except (KeyError, TypeError, ValueError):
|
|
139
|
+
return None
|
|
@@ -50,7 +50,7 @@ class SwitchContext:
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
class Fetcher(Protocol):
|
|
53
|
-
"""Fetch a candidate
|
|
53
|
+
"""Fetch a candidate version; return the interpreter it will run from.
|
|
54
54
|
|
|
55
55
|
The interpreter names the candidate's **own environment**, which VERIFY runs
|
|
56
56
|
in (NF-2). The version dir is materialised at STAGE, after verification.
|
|
@@ -109,12 +109,12 @@ def run_switch(
|
|
|
109
109
|
"""
|
|
110
110
|
bounds = bounds or Bounds()
|
|
111
111
|
|
|
112
|
-
# NO-OP — applying the
|
|
112
|
+
# NO-OP — applying the version already current changes nothing (NF-5). Return
|
|
113
113
|
# before fetch/verify/stage/commit, so the child is NOT restarted: under B1 a
|
|
114
114
|
# re-apply would otherwise be a full switch and restart the node for no change
|
|
115
115
|
# (the daily loop that never converges). `force` does not bypass this: it is
|
|
116
116
|
# the not-newer override, and a deliberate bounce is a unit restart.
|
|
117
|
-
if version_tree.
|
|
117
|
+
if version_tree.is_current_version(request.target):
|
|
118
118
|
return _outcome(request, SeamState.SKIPPED, "target is already current")
|
|
119
119
|
|
|
120
120
|
last_good_dir = version_tree.selected()
|