manta-node 0.6b5.dev697__py3-none-any.whl → 0.6b5.dev702__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/cli/commands/status.py +45 -69
- manta_node/cli/commands/stop.py +69 -48
- manta_node/infrastructure/mqtt/command_handler.py +29 -0
- manta_node/seam.py +139 -0
- manta_node/supervisor/__init__.py +11 -0
- manta_node/supervisor/process.py +210 -0
- manta_node/supervisor/records.py +167 -0
- manta_node/supervisor/seam.py +190 -0
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.dist-info}/METADATA +1 -1
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.dist-info}/RECORD +15 -10
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.dist-info}/entry_points.txt +1 -0
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.dist-info}/WHEEL +0 -0
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.dist-info}/licenses/LICENSE +0 -0
- {manta_node-0.6b5.dev697.dist-info → manta_node-0.6b5.dev702.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.24.10597326"
|
|
8
|
+
__build_date__ = "2026.09.24"
|
|
9
|
+
__commit_id__ = "10597326"
|
|
10
10
|
__package_version__ = "0.6b5"
|
|
@@ -2,88 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import contextlib
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
5
|
from datetime import datetime
|
|
8
|
-
from pathlib import Path
|
|
9
6
|
|
|
10
7
|
import psutil
|
|
11
8
|
from rich.console import Console
|
|
12
9
|
from rich.table import Table
|
|
13
10
|
|
|
11
|
+
from ...supervisor.records import list_instances
|
|
12
|
+
|
|
14
13
|
console = Console()
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
def find_running_instances():
|
|
18
|
-
"""
|
|
17
|
+
"""Running instances for display, with the supervisor record read-through.
|
|
19
18
|
|
|
20
|
-
|
|
19
|
+
Under a supervisor the pid of record is the supervisor's, and only the
|
|
20
|
+
supervisor record exists (the child runs with ``--subprocess``); without one
|
|
21
|
+
it is the node's own instance record. ``list_instances`` resolves both.
|
|
22
|
+
"""
|
|
21
23
|
instances = []
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
for record in list_instances():
|
|
25
|
+
pid = record["pid"]
|
|
26
|
+
# The workload is the child under a supervisor, the node itself without
|
|
27
|
+
# one: `status` shows its cpu/memory, while `pid` stays the pid a stop
|
|
28
|
+
# signals (the supervisor's, when present).
|
|
29
|
+
workload_pid = record.get("workload_pid") or pid
|
|
30
|
+
cpu_percent = None
|
|
31
|
+
memory_mb = None
|
|
32
|
+
create_time = None
|
|
33
|
+
status = "running"
|
|
27
34
|
try:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# Add additional info from JSON
|
|
58
|
-
alias = instance_data.get("alias", instance_id)
|
|
59
|
-
config_name = instance_data.get("config_name", "unknown")
|
|
60
|
-
start_time_str = instance_data.get("start_time")
|
|
61
|
-
if start_time_str and not create_time:
|
|
62
|
-
with contextlib.suppress(ValueError, TypeError):
|
|
63
|
-
create_time = datetime.fromisoformat(start_time_str)
|
|
64
|
-
|
|
65
|
-
instances.append(
|
|
66
|
-
{
|
|
67
|
-
"instance_id": instance_id,
|
|
68
|
-
"alias": alias,
|
|
69
|
-
"config_name": config_name,
|
|
70
|
-
"pid": pid,
|
|
71
|
-
"status": status,
|
|
72
|
-
"cpu_percent": cpu_percent,
|
|
73
|
-
"memory_mb": memory_mb,
|
|
74
|
-
"start_time": create_time,
|
|
75
|
-
"instance_file": instance_file,
|
|
76
|
-
}
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
except OSError:
|
|
80
|
-
# Process no longer exists, remove stale instance file
|
|
81
|
-
instance_file.unlink(missing_ok=True)
|
|
82
|
-
|
|
83
|
-
except (ValueError, FileNotFoundError):
|
|
84
|
-
# Invalid instance file, remove it
|
|
85
|
-
instance_file.unlink(missing_ok=True)
|
|
86
|
-
|
|
35
|
+
process = psutil.Process(workload_pid)
|
|
36
|
+
cpu_percent = process.cpu_percent()
|
|
37
|
+
memory_mb = process.memory_info().rss // (1024 * 1024)
|
|
38
|
+
create_time = datetime.fromtimestamp(process.create_time())
|
|
39
|
+
except ImportError:
|
|
40
|
+
pass
|
|
41
|
+
except psutil.NoSuchProcess:
|
|
42
|
+
# Disappeared between the liveness check and here; skip it.
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
start_time_str = record.get("start_time")
|
|
46
|
+
if start_time_str and not create_time:
|
|
47
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
48
|
+
create_time = datetime.fromisoformat(start_time_str)
|
|
49
|
+
|
|
50
|
+
instances.append(
|
|
51
|
+
{
|
|
52
|
+
"instance_id": record["instance_id"],
|
|
53
|
+
"alias": record["alias"],
|
|
54
|
+
"config_name": record["config_name"],
|
|
55
|
+
"pid": pid,
|
|
56
|
+
"status": status,
|
|
57
|
+
"cpu_percent": cpu_percent,
|
|
58
|
+
"memory_mb": memory_mb,
|
|
59
|
+
"start_time": create_time,
|
|
60
|
+
"instance_file": record["instance_file"],
|
|
61
|
+
}
|
|
62
|
+
)
|
|
87
63
|
return instances
|
|
88
64
|
|
|
89
65
|
|
manta_node/cli/commands/stop.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"""Node stop command implementation."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import json
|
|
5
4
|
import os
|
|
6
5
|
import signal
|
|
7
6
|
import time
|
|
8
|
-
from pathlib import Path
|
|
9
7
|
|
|
10
8
|
from rich.console import Console
|
|
11
9
|
|
|
10
|
+
from ...supervisor.records import list_instances
|
|
11
|
+
|
|
12
12
|
console = Console()
|
|
13
13
|
|
|
14
14
|
|
|
@@ -28,45 +28,23 @@ def print_warning(message: str):
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def find_running_instances():
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
instances_dir = Path.home() / ".manta" / "nodes" / "instances"
|
|
34
|
-
instances = []
|
|
35
|
-
|
|
36
|
-
if not instances_dir.exists():
|
|
37
|
-
return instances
|
|
38
|
-
|
|
39
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
40
|
-
try:
|
|
41
|
-
with open(instance_file, "r") as f:
|
|
42
|
-
instance_data = json.load(f)
|
|
43
|
-
|
|
44
|
-
pid = instance_data.get("pid")
|
|
45
|
-
instance_id = instance_data.get("instance_id", instance_file.stem)
|
|
46
|
-
alias = instance_data.get("alias", instance_id)
|
|
47
|
-
|
|
48
|
-
# Check if process is still running
|
|
49
|
-
try:
|
|
50
|
-
# Send signal 0 to check if process exists
|
|
51
|
-
|
|
52
|
-
os.kill(pid, 0)
|
|
53
|
-
instances.append(
|
|
54
|
-
{
|
|
55
|
-
"instance_id": instance_id,
|
|
56
|
-
"alias": alias,
|
|
57
|
-
"pid": pid,
|
|
58
|
-
"instance_file": instance_file,
|
|
59
|
-
}
|
|
60
|
-
)
|
|
61
|
-
except OSError:
|
|
62
|
-
# Process no longer exists, remove stale instance file
|
|
63
|
-
instance_file.unlink(missing_ok=True)
|
|
64
|
-
|
|
65
|
-
except (ValueError, FileNotFoundError):
|
|
66
|
-
# Invalid instance file, remove it
|
|
67
|
-
instance_file.unlink(missing_ok=True)
|
|
31
|
+
"""Running instances to stop, with the supervisor record read-through.
|
|
68
32
|
|
|
69
|
-
|
|
33
|
+
The pid returned for each alias is the **pid of record**: the supervisor's
|
|
34
|
+
when one is present, else the node's own. Signalling it stops the supervisor
|
|
35
|
+
(which winds the child down) or the node directly — never both.
|
|
36
|
+
"""
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
"instance_id": record["instance_id"],
|
|
40
|
+
"alias": record["alias"],
|
|
41
|
+
"pid": record["pid"],
|
|
42
|
+
"child_pid": record.get("child_pid"),
|
|
43
|
+
"source": record["source"],
|
|
44
|
+
"instance_file": record["instance_file"],
|
|
45
|
+
}
|
|
46
|
+
for record in list_instances()
|
|
47
|
+
]
|
|
70
48
|
|
|
71
49
|
|
|
72
50
|
def stop_instance_by_pid(
|
|
@@ -85,15 +63,53 @@ def stop_instance_by_pid(
|
|
|
85
63
|
pid = instance["pid"]
|
|
86
64
|
instance_id = instance["instance_id"]
|
|
87
65
|
alias = instance.get("alias", instance_id)
|
|
66
|
+
# A supervised node's pid of record is the SUPERVISOR, and its child leads a
|
|
67
|
+
# SEPARATE process group (`start_new_session=True`), so `killpg` on the
|
|
68
|
+
# supervisor never reaches the child. A bare SIGKILL of the supervisor cannot
|
|
69
|
+
# be forwarded and would orphan the child. So: SIGTERM the supervisor, which
|
|
70
|
+
# forwards to the child and exits; to force, also SIGKILL the recorded child
|
|
71
|
+
# pid, then the supervisor.
|
|
72
|
+
owned_by_supervisor = instance.get("source") == "supervisor"
|
|
73
|
+
child_pid = instance.get("child_pid")
|
|
74
|
+
|
|
75
|
+
def signal_supervisor(signum: int) -> None:
|
|
76
|
+
_signal_pid(pid, signum)
|
|
77
|
+
|
|
78
|
+
def signal_child(signum: int) -> None:
|
|
79
|
+
# The child is its own session: signal the group it leads, so any task
|
|
80
|
+
# grandchildren it spawned go too. Fall back to the pid if the group is
|
|
81
|
+
# gone or not ours.
|
|
82
|
+
if not child_pid:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
os.killpg(os.getpgid(child_pid), signum)
|
|
86
|
+
except (ProcessLookupError, PermissionError):
|
|
87
|
+
_signal_pid(child_pid, signum)
|
|
88
|
+
|
|
89
|
+
def _signal_pid(target: int | None, signum: int) -> None:
|
|
90
|
+
if not target:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
os.kill(target, signum)
|
|
94
|
+
except ProcessLookupError:
|
|
95
|
+
pass
|
|
88
96
|
|
|
89
97
|
try:
|
|
90
98
|
if force:
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
if owned_by_supervisor:
|
|
100
|
+
# Force: SIGTERM the supervisor (it forwards and exits), then
|
|
101
|
+
# SIGKILL the child directly and the supervisor — neither can be
|
|
102
|
+
# orphaned. A plain SIGKILL of the supervisor alone would orphan
|
|
103
|
+
# the child, which leads its own session.
|
|
104
|
+
signal_supervisor(signal.SIGTERM)
|
|
105
|
+
signal_child(signal.SIGKILL)
|
|
106
|
+
signal_supervisor(signal.SIGKILL)
|
|
107
|
+
else:
|
|
108
|
+
_signal_pid(pid, signal.SIGKILL)
|
|
93
109
|
console.print(f"Force killed node '{alias}' (PID: {pid})")
|
|
94
110
|
else:
|
|
95
111
|
# Graceful shutdown with SIGTERM
|
|
96
|
-
|
|
112
|
+
signal_supervisor(signal.SIGTERM)
|
|
97
113
|
console.print(f"Sent termination signal to node '{alias}' (PID: {pid})")
|
|
98
114
|
|
|
99
115
|
# Wait for process to terminate gracefully
|
|
@@ -111,21 +127,26 @@ def stop_instance_by_pid(
|
|
|
111
127
|
console.print(f"[green]Node '{alias}' stopped gracefully[/green]")
|
|
112
128
|
break
|
|
113
129
|
else:
|
|
114
|
-
# Timeout reached,
|
|
130
|
+
# Timeout reached: force the child and the supervisor, never the
|
|
131
|
+
# supervisor alone (which would orphan the child).
|
|
115
132
|
print_warning(
|
|
116
133
|
f"Node '{alias}' did not stop gracefully, sending SIGKILL..."
|
|
117
134
|
)
|
|
118
|
-
|
|
135
|
+
signal_child(signal.SIGKILL)
|
|
136
|
+
signal_supervisor(signal.SIGKILL)
|
|
119
137
|
console.print(f"Force killed node '{alias}' after timeout")
|
|
120
138
|
|
|
121
|
-
#
|
|
122
|
-
|
|
139
|
+
# Only the node's own instance record is the CLI's to remove; a
|
|
140
|
+
# supervisor record is cleared by the supervisor as it exits.
|
|
141
|
+
if not owned_by_supervisor:
|
|
142
|
+
instance["instance_file"].unlink(missing_ok=True)
|
|
123
143
|
return True
|
|
124
144
|
|
|
125
145
|
except OSError as e:
|
|
126
146
|
if e.errno == 3: # No such process
|
|
127
147
|
# Process already dead, just remove instance file
|
|
128
|
-
|
|
148
|
+
if not owned_by_supervisor:
|
|
149
|
+
instance["instance_file"].unlink(missing_ok=True)
|
|
129
150
|
print_warning(f"Node '{alias}' was already stopped")
|
|
130
151
|
return True
|
|
131
152
|
else:
|
|
@@ -23,6 +23,12 @@ from manta_common.errors import (
|
|
|
23
23
|
from manta_common.retry import RetryPolicy
|
|
24
24
|
from manta_common.traces import Tracer
|
|
25
25
|
|
|
26
|
+
from ...seam import (
|
|
27
|
+
SeamClient,
|
|
28
|
+
SeamError,
|
|
29
|
+
SwitchOutcome,
|
|
30
|
+
UpdateRequest,
|
|
31
|
+
)
|
|
26
32
|
from ...task_manager import LOG_FLUSH_INTERVAL, LogStream, TaskManager
|
|
27
33
|
from ...tasks import TaskProgression
|
|
28
34
|
from ..filesystem.dataset_manager import DatasetManager
|
|
@@ -128,8 +134,13 @@ class CommandHandler(Collector, MqttBase):
|
|
|
128
134
|
ping_interval: int = TIMEOUT,
|
|
129
135
|
max_concurrent_tasks: int = 2,
|
|
130
136
|
task_timeout: int = 3600,
|
|
137
|
+
seam_client: SeamClient | None = None,
|
|
131
138
|
) -> None:
|
|
132
139
|
self.tracer = Tracer(getLogger(__name__))
|
|
140
|
+
# The local seam to a supervisor, when one owns this node (2b). None on a
|
|
141
|
+
# node running without a supervisor: the relay is inert, and the handler
|
|
142
|
+
# behaves exactly as before (2b acceptance: unused ⇒ no change).
|
|
143
|
+
self._seam_client = seam_client
|
|
133
144
|
self._task_manager = task_runner
|
|
134
145
|
self.node_client = node_client
|
|
135
146
|
self.dataset_manager = dataset_manager
|
|
@@ -669,6 +680,24 @@ class CommandHandler(Collector, MqttBase):
|
|
|
669
680
|
"""
|
|
670
681
|
await self.stop_callback()
|
|
671
682
|
|
|
683
|
+
async def relay_update(self, request: UpdateRequest) -> SwitchOutcome | None:
|
|
684
|
+
"""Relay an update request to the supervisor over the seam (2b).
|
|
685
|
+
|
|
686
|
+
The node is a **relay**: it holds the control channel and passes the
|
|
687
|
+
declaration to the supervisor, which executes. With no seam client (a
|
|
688
|
+
node running without a supervisor) this is inert and returns None — no
|
|
689
|
+
behaviour change. If the seam is unreachable, the failure is reported and
|
|
690
|
+
nothing is mutated (NF-3): the node does not fall back to an in-place
|
|
691
|
+
install.
|
|
692
|
+
"""
|
|
693
|
+
if self._seam_client is None:
|
|
694
|
+
return None
|
|
695
|
+
try:
|
|
696
|
+
return await self._seam_client.request_update(request)
|
|
697
|
+
except SeamError as exc:
|
|
698
|
+
self.tracer.exception(f"Seam relay failed: {exc}")
|
|
699
|
+
raise MantaNodeError(f"Supervisor seam unavailable: {exc}") from exc
|
|
700
|
+
|
|
672
701
|
async def on_task_setup_failed(self, task: MqttTask, exc: Exception):
|
|
673
702
|
"""
|
|
674
703
|
Publish a FAILED task update when task setup fails.
|
manta_node/seam.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The local seam — the port between the running node and its supervisor.
|
|
2
|
+
|
|
3
|
+
The seam is the node/domain's **port** (mechanism §6.4): the request/outcome
|
|
4
|
+
interface the running node calls to ask its supervisor to act. The supervisor
|
|
5
|
+
**implements** it with a Unix-socket adapter (`manta_node/supervisor/seam.py`).
|
|
6
|
+
Only the node calls; nothing off-host reaches the supervisor directly.
|
|
7
|
+
|
|
8
|
+
This module is deliberately **dependency-free** (stdlib only, no `manta_node` or
|
|
9
|
+
`manta_common` import): it is the shared interface, and the supervisor imports it
|
|
10
|
+
without taking on any domain logic — the guard in
|
|
11
|
+
`tests/test_supervisor_is_infrastructure.py` carves it out for exactly that
|
|
12
|
+
reason. Keeping it total (messages and the abstract client) means both sides can
|
|
13
|
+
depend on it without a cycle.
|
|
14
|
+
|
|
15
|
+
**Contract** (mechanism §6.4). The request carries `request_id` (idempotency),
|
|
16
|
+
the `target` (a pair or set id — the node computes no ordering), `force`, and the
|
|
17
|
+
`declared_sequence` the Manager passed through. The supervisor answers with a
|
|
18
|
+
`state` and a `reason`. The switch restarts the child, so the outcome across a
|
|
19
|
+
switch is observed **asynchronously** after the node comes back (NF-8); the seam
|
|
20
|
+
carries initiation synchronously and the immediate outcome.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Protocol, runtime_checkable
|
|
30
|
+
|
|
31
|
+
# The socket lives in the node's state dir, never the version tree (NF-6), with
|
|
32
|
+
# 0600 on the socket and its parent. It is local and same-user only (NF-9/NF-10).
|
|
33
|
+
SEAM_SOCKET_NAME = "supervisor.sock"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def seam_socket_path() -> Path:
|
|
37
|
+
"""The Unix-socket path for the seam (in the node's state directory)."""
|
|
38
|
+
return Path.home() / ".manta" / SEAM_SOCKET_NAME
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SeamState(str, Enum):
|
|
42
|
+
"""The states the supervisor answers with (mechanism §6.4)."""
|
|
43
|
+
|
|
44
|
+
REQUESTED = "requested"
|
|
45
|
+
REFUSED = "refused"
|
|
46
|
+
FETCHING = "fetching"
|
|
47
|
+
VERIFYING = "verifying"
|
|
48
|
+
STAGED = "staged"
|
|
49
|
+
SWITCHING = "switching"
|
|
50
|
+
RESTARTING = "restarting"
|
|
51
|
+
REVERTING = "reverting"
|
|
52
|
+
REPORTING = "reporting"
|
|
53
|
+
REVERTED = "reverted"
|
|
54
|
+
FAILED = "failed"
|
|
55
|
+
DOWN = "down"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class UpdateRequest:
|
|
60
|
+
"""A request from the running node to its supervisor.
|
|
61
|
+
|
|
62
|
+
``target`` is the pair (or a set id that resolves to one); the node computes
|
|
63
|
+
no ordering, it passes the declaration through. ``declared_sequence`` is the
|
|
64
|
+
Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of a
|
|
65
|
+
request already in flight or completed is a no-op (NF-5).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
request_id: str
|
|
69
|
+
target: str
|
|
70
|
+
force: bool = False
|
|
71
|
+
declared_sequence: int | None = None
|
|
72
|
+
|
|
73
|
+
def to_bytes(self) -> bytes:
|
|
74
|
+
return json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"request_id": self.request_id,
|
|
77
|
+
"target": self.target,
|
|
78
|
+
"force": self.force,
|
|
79
|
+
"declared_sequence": self.declared_sequence,
|
|
80
|
+
}
|
|
81
|
+
).encode()
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_bytes(cls, raw: bytes) -> UpdateRequest:
|
|
85
|
+
data = json.loads(raw)
|
|
86
|
+
return cls(
|
|
87
|
+
request_id=data["request_id"],
|
|
88
|
+
target=data["target"],
|
|
89
|
+
force=bool(data.get("force", False)),
|
|
90
|
+
declared_sequence=data.get("declared_sequence"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class SwitchOutcome:
|
|
96
|
+
"""The supervisor's answer to a request: a state and a reason."""
|
|
97
|
+
|
|
98
|
+
request_id: str
|
|
99
|
+
state: SeamState
|
|
100
|
+
reason: str = ""
|
|
101
|
+
detail: dict = field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
def to_bytes(self) -> bytes:
|
|
104
|
+
return json.dumps(
|
|
105
|
+
{
|
|
106
|
+
"request_id": self.request_id,
|
|
107
|
+
"state": self.state.value,
|
|
108
|
+
"reason": self.reason,
|
|
109
|
+
"detail": self.detail,
|
|
110
|
+
}
|
|
111
|
+
).encode()
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_bytes(cls, raw: bytes) -> SwitchOutcome:
|
|
115
|
+
data = json.loads(raw)
|
|
116
|
+
return cls(
|
|
117
|
+
request_id=data["request_id"],
|
|
118
|
+
state=SeamState(data["state"]),
|
|
119
|
+
reason=data.get("reason", ""),
|
|
120
|
+
detail=data.get("detail", {}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SeamError(Exception):
|
|
125
|
+
"""The seam could not carry the request (unreachable, malformed, refused)."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@runtime_checkable
|
|
129
|
+
class SeamClient(Protocol):
|
|
130
|
+
"""The port the running node calls. Implemented by the socket adapter."""
|
|
131
|
+
|
|
132
|
+
async def request_update(self, request: UpdateRequest) -> SwitchOutcome:
|
|
133
|
+
"""Carry a request to the supervisor and return its immediate outcome.
|
|
134
|
+
|
|
135
|
+
Raises ``SeamError`` if the seam is unreachable: the node reports the
|
|
136
|
+
failure and mutates nothing (NF-3); it does not fall back to an in-place
|
|
137
|
+
install.
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The node supervisor — infrastructure that owns the node child (ADR-0041 B1).
|
|
2
|
+
|
|
3
|
+
The supervisor is a long-lived process that starts ``manta_node start <alias>``
|
|
4
|
+
as its child and owns the child's restart. It is **infrastructure, not domain**:
|
|
5
|
+
it makes no decision about versions, drift, fleets or outcomes, and it imports
|
|
6
|
+
nothing domain-related from ``manta_node`` (pinned by a guard test). Making a
|
|
7
|
+
version current, verifying a candidate and rolling back are later steps (2c/2d);
|
|
8
|
+
this package is the process manager only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__ = []
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The supervisor process manager — starts the node child and owns its restart.
|
|
2
|
+
|
|
3
|
+
The systemd unit's ``ExecStart`` becomes ``manta-supervisor <alias>``; the
|
|
4
|
+
supervisor starts ``manta_node start <alias>`` as its child, records itself as the
|
|
5
|
+
pid of record, restarts the child under a **bounded** policy, and treats a stop
|
|
6
|
+
signal as **terminal**. Two restart owners never coexist: the unit restarts only
|
|
7
|
+
the supervisor, the supervisor restarts only the child.
|
|
8
|
+
|
|
9
|
+
Spike A (manta-node-python#483) proved the shape this implements; the prototype
|
|
10
|
+
is manta-node-python#484. The guarantee that a supervisor killed outright leaves
|
|
11
|
+
no orphan is the unit's ``KillMode=control-group`` (systemd reaps the cgroup);
|
|
12
|
+
``start_new_session=True`` below is the belt-and-braces process-group path for a
|
|
13
|
+
supervisor stopped outside systemd.
|
|
14
|
+
|
|
15
|
+
**Placement.** At 2a the supervisor runs from the node's own venv, because the
|
|
16
|
+
child's interpreter *is* the supervisor's there. The migration's "supervisor
|
|
17
|
+
installed outside the node venv" is satisfied at 2c, when the child runs from the
|
|
18
|
+
version tree; ``--python`` (``child_python``) selects that interpreter, so the
|
|
19
|
+
supervisor can then live in its own venv without running the node code from the
|
|
20
|
+
wrong one.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from .records import (
|
|
34
|
+
supervisor_record,
|
|
35
|
+
supervisor_record_path,
|
|
36
|
+
write_json_atomic,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# A child that exits more than RESTART_LIMIT times within RESTART_WINDOW_SECONDS
|
|
40
|
+
# is a crash loop: the supervisor stops restarting and exits non-zero, so systemd
|
|
41
|
+
# sees a failed unit rather than an infinitely restarting one.
|
|
42
|
+
RESTART_LIMIT = 5
|
|
43
|
+
RESTART_WINDOW_SECONDS = 120
|
|
44
|
+
RESTART_BACKOFF_SECONDS = 2
|
|
45
|
+
|
|
46
|
+
# Bound on the supervisor's stdout stream. A long-lived supervisor reopens this
|
|
47
|
+
# on every restart, so without a cap a crash loop grows it without limit.
|
|
48
|
+
STDOUT_MAX_BYTES = 10 * 1024 * 1024
|
|
49
|
+
|
|
50
|
+
# How long a terminal stop waits for the child to wind down before SIGKILL.
|
|
51
|
+
STOP_GRACE_SECONDS = 10
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _log_dir() -> Path:
|
|
55
|
+
return Path.home() / ".manta" / "logs" / "nodes"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Supervisor:
|
|
59
|
+
"""Runs one node child, restarts it (bounded), and stops it terminally."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self, alias: str, config: str, child_python: str | None = None
|
|
63
|
+
) -> None:
|
|
64
|
+
self.alias = alias
|
|
65
|
+
self.config = config
|
|
66
|
+
# The interpreter that runs the child. Today the supervisor and the node
|
|
67
|
+
# code share one venv, so this defaults to the supervisor's own; at 2c
|
|
68
|
+
# the child runs from the version tree's interpreter, a DIFFERENT venv
|
|
69
|
+
# from the supervisor's, and the unit passes that interpreter here.
|
|
70
|
+
self.child_python = child_python or sys.executable
|
|
71
|
+
self._stop = False
|
|
72
|
+
self._child: subprocess.Popen | None = None
|
|
73
|
+
self._restarts: list[float] = []
|
|
74
|
+
|
|
75
|
+
def _write_record(self, child_pid: int | None, state: str) -> None:
|
|
76
|
+
write_json_atomic(
|
|
77
|
+
supervisor_record_path(self.alias),
|
|
78
|
+
supervisor_record(self.alias, os.getpid(), child_pid, state),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _signal_child(self, signum: int) -> None:
|
|
82
|
+
"""Signal the child's process group, falling back to the child alone.
|
|
83
|
+
|
|
84
|
+
The child is spawned with ``start_new_session=True``, so it leads its own
|
|
85
|
+
group; signalling the group takes any grandchildren with it.
|
|
86
|
+
"""
|
|
87
|
+
if not self._child or self._child.poll() is not None:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
os.killpg(os.getpgid(self._child.pid), signum)
|
|
91
|
+
except (ProcessLookupError, PermissionError):
|
|
92
|
+
self._child.send_signal(signum)
|
|
93
|
+
|
|
94
|
+
def _on_signal(self, signum: int, _frame) -> None:
|
|
95
|
+
# A stop is terminal: remember it and forward it. The main loop must not
|
|
96
|
+
# restart once this is set.
|
|
97
|
+
self._stop = True
|
|
98
|
+
self._signal_child(signal.SIGTERM)
|
|
99
|
+
|
|
100
|
+
def _spawn(self) -> subprocess.Popen:
|
|
101
|
+
# `--subprocess` is the existing hidden flag: the child does not write an
|
|
102
|
+
# instance file, so exactly one writer (the supervisor) owns the records.
|
|
103
|
+
# `--foreground` keeps it attached to this process rather than detaching.
|
|
104
|
+
# `--alias` is passed explicitly: `--config` names the TOML to load, and
|
|
105
|
+
# the alias the operator sees must be the supervisor's, not whatever
|
|
106
|
+
# `identity.alias` the TOML happens to carry.
|
|
107
|
+
cmd = [
|
|
108
|
+
self.child_python,
|
|
109
|
+
"-m",
|
|
110
|
+
"manta_node",
|
|
111
|
+
"start",
|
|
112
|
+
self.config,
|
|
113
|
+
"--foreground",
|
|
114
|
+
"--subprocess",
|
|
115
|
+
"--alias",
|
|
116
|
+
self.alias,
|
|
117
|
+
]
|
|
118
|
+
log_dir = _log_dir()
|
|
119
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
# One writer per path: the supervisor owns the child's stdout/stderr
|
|
121
|
+
# stream. The child's own rotating file log is separate and untouched.
|
|
122
|
+
# A long-lived supervisor reopens this on every restart, so it is bounded
|
|
123
|
+
# like the child's own rotating handler: roll once past the cap, keeping
|
|
124
|
+
# one previous run. Roll BEFORE the open, and never while a writer holds
|
|
125
|
+
# it — a rename does not retarget an already-open fd.
|
|
126
|
+
stdout_path = log_dir / f"{self.alias}.supervisor.stdout"
|
|
127
|
+
if stdout_path.exists() and stdout_path.stat().st_size >= STDOUT_MAX_BYTES:
|
|
128
|
+
stdout_path.replace(stdout_path.with_name(f"{stdout_path.name}.1"))
|
|
129
|
+
with open(stdout_path, "a") as stream:
|
|
130
|
+
child = subprocess.Popen(
|
|
131
|
+
cmd, stdout=stream, stderr=stream, start_new_session=True
|
|
132
|
+
)
|
|
133
|
+
# Publish the pid BEFORE returning: a signal that fires between here and
|
|
134
|
+
# the caller's `_stop` check must be able to reach the child, not a gone
|
|
135
|
+
# one.
|
|
136
|
+
self._child = child
|
|
137
|
+
return child
|
|
138
|
+
|
|
139
|
+
def run(self) -> int:
|
|
140
|
+
signal.signal(signal.SIGTERM, self._on_signal)
|
|
141
|
+
signal.signal(signal.SIGINT, self._on_signal)
|
|
142
|
+
self._write_record(None, "starting")
|
|
143
|
+
|
|
144
|
+
while not self._stop:
|
|
145
|
+
# `_spawn` publishes `self._child` before returning, so a signal
|
|
146
|
+
# during the fork reaches the new pid; one arriving just after is
|
|
147
|
+
# caught by the `_stop` re-check below.
|
|
148
|
+
child = self._spawn()
|
|
149
|
+
if self._stop:
|
|
150
|
+
# A stop arrived while we forked: forward it and do not restart.
|
|
151
|
+
# Without this the loop would block on a child never signalled.
|
|
152
|
+
self._signal_child(signal.SIGTERM)
|
|
153
|
+
self._write_record(child.pid, "running")
|
|
154
|
+
child.wait()
|
|
155
|
+
rc = child.returncode
|
|
156
|
+
|
|
157
|
+
if self._stop:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
now = time.monotonic()
|
|
161
|
+
self._restarts = [
|
|
162
|
+
t for t in self._restarts if now - t < RESTART_WINDOW_SECONDS
|
|
163
|
+
]
|
|
164
|
+
self._restarts.append(now)
|
|
165
|
+
if len(self._restarts) > RESTART_LIMIT:
|
|
166
|
+
self._write_record(None, "crash-loop")
|
|
167
|
+
print(
|
|
168
|
+
f"supervisor: child exited {len(self._restarts)} times in "
|
|
169
|
+
f"{RESTART_WINDOW_SECONDS}s (rc={rc}); refusing to restart",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
self._write_record(None, "restarting")
|
|
175
|
+
time.sleep(RESTART_BACKOFF_SECONDS)
|
|
176
|
+
|
|
177
|
+
# Terminal stop: wind the child down, then clear our record so `status`
|
|
178
|
+
# does not report a dead supervisor.
|
|
179
|
+
if self._child and self._child.poll() is None:
|
|
180
|
+
self._signal_child(signal.SIGTERM)
|
|
181
|
+
try:
|
|
182
|
+
self._child.wait(timeout=STOP_GRACE_SECONDS)
|
|
183
|
+
except subprocess.TimeoutExpired:
|
|
184
|
+
self._signal_child(signal.SIGKILL)
|
|
185
|
+
supervisor_record_path(self.alias).unlink(missing_ok=True)
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv: list[str] | None = None) -> int:
|
|
190
|
+
parser = argparse.ArgumentParser(
|
|
191
|
+
prog="manta-supervisor",
|
|
192
|
+
description="Own a manta_node child and its restart (ADR-0041 boundary B1).",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("alias", help="node alias (the config name it starts)")
|
|
195
|
+
parser.add_argument("--config", help="config name (defaults to the alias)")
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--python",
|
|
198
|
+
help=(
|
|
199
|
+
"interpreter that runs the node child (defaults to the supervisor's"
|
|
200
|
+
" own; set it when the child runs from a separate version tree, 2c)"
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
args = parser.parse_args(argv)
|
|
204
|
+
return Supervisor(
|
|
205
|
+
args.alias, args.config or args.alias, child_python=args.python
|
|
206
|
+
).run()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The two on-disk records the supervisor and the node keep for one instance.
|
|
2
|
+
|
|
3
|
+
Under the split (ADR-0041 boundary B1) there are two processes and two records:
|
|
4
|
+
|
|
5
|
+
- the **node instance record**, ``<id>.json`` — node-owned content (alias,
|
|
6
|
+
config, ``log_file``, manager host). The running node writes it; the
|
|
7
|
+
supervisor never does.
|
|
8
|
+
- the **supervisor record**, ``<alias>.supervisor.json`` — supervisor-owned, and
|
|
9
|
+
the **pid of record**. Under ``--subprocess`` the child writes no instance
|
|
10
|
+
record (``start.py`` skips it), so on a supervised node this is the only record
|
|
11
|
+
present and the CLI's ``status``/``stop`` must read it.
|
|
12
|
+
|
|
13
|
+
This module has no dependency on the node's domain: the supervisor is
|
|
14
|
+
infrastructure, and a guard test pins that (``tests/.../test_supervisor_is_infrastructure``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
INSTANCE_DIR = Path.home() / ".manta" / "nodes" / "instances"
|
|
25
|
+
|
|
26
|
+
SUPERVISOR_SUFFIX = ".supervisor.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def supervisor_record_path(alias: str) -> Path:
|
|
30
|
+
"""The supervisor record for ``alias`` (the pid of record)."""
|
|
31
|
+
return INSTANCE_DIR / f"{alias}{SUPERVISOR_SUFFIX}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_json_atomic(path: Path, data: dict) -> None:
|
|
35
|
+
"""Write ``data`` as JSON so no reader ever sees a half-written file.
|
|
36
|
+
|
|
37
|
+
A tmp file beside the target plus ``replace`` — an atomic rename on the same
|
|
38
|
+
filesystem. A reader either sees the previous record or the new one.
|
|
39
|
+
"""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
42
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
43
|
+
tmp.replace(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_json(path: Path) -> dict | None:
|
|
47
|
+
"""Read a JSON object, or None if it is absent or unreadable/invalid."""
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text())
|
|
50
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def supervisor_record(
|
|
55
|
+
alias: str, supervisor_pid: int, child_pid: int | None, state: str
|
|
56
|
+
) -> dict:
|
|
57
|
+
"""The supervisor record's shape."""
|
|
58
|
+
return {
|
|
59
|
+
"alias": alias,
|
|
60
|
+
"supervisor_pid": supervisor_pid,
|
|
61
|
+
"child_pid": child_pid,
|
|
62
|
+
"state": state,
|
|
63
|
+
"start_time": datetime.now(timezone.utc).isoformat(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_alive(pid: int | None) -> bool:
|
|
68
|
+
"""True when ``pid`` names a live process."""
|
|
69
|
+
if not pid:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
os.kill(pid, 0)
|
|
73
|
+
except OSError:
|
|
74
|
+
return False
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pid_of_record(alias: str) -> tuple[int | None, str]:
|
|
79
|
+
"""(pid, source) for ``alias``: the supervisor record wins when it is live.
|
|
80
|
+
|
|
81
|
+
The pid of record is the one a stop signals. Under the split that is the
|
|
82
|
+
supervisor's; the instance record (the node's own pid) is the fallback for a
|
|
83
|
+
node running without a supervisor.
|
|
84
|
+
"""
|
|
85
|
+
sup = read_json(supervisor_record_path(alias))
|
|
86
|
+
if sup and is_alive(sup.get("supervisor_pid")):
|
|
87
|
+
return sup["supervisor_pid"], "supervisor"
|
|
88
|
+
|
|
89
|
+
# Fall back to the node instance record: the supervisor may be absent (a node
|
|
90
|
+
# on the pre-split path) or dead. Match on the record's own alias, not the
|
|
91
|
+
# filename stem, which carries the per-start instance id.
|
|
92
|
+
for record in sorted(INSTANCE_DIR.glob("*.json")):
|
|
93
|
+
if record.name.endswith(SUPERVISOR_SUFFIX):
|
|
94
|
+
continue
|
|
95
|
+
data = read_json(record)
|
|
96
|
+
if data and data.get("alias") == alias and is_alive(data.get("pid")):
|
|
97
|
+
return data["pid"], "instance"
|
|
98
|
+
return None, "none"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_instances() -> list[dict]:
|
|
102
|
+
"""Live instances for the CLI, one per alias: the pid of record and its source.
|
|
103
|
+
|
|
104
|
+
A supervised node has only a supervisor record; a pre-split node has only an
|
|
105
|
+
instance record. A stale record (the pid is gone) is not returned, and its
|
|
106
|
+
file is removed — the same cleanup the CLI did before the split.
|
|
107
|
+
"""
|
|
108
|
+
# Collect the two kinds separately, then resolve per alias with an EXPLICIT
|
|
109
|
+
# rule — never by filename/glob order. A supervisor record always wins over a
|
|
110
|
+
# node instance record for the same alias (as `pid_of_record` does); relying
|
|
111
|
+
# on sort order would let a legacy instance record silently override a live
|
|
112
|
+
# supervisor record whenever its config name sorts after the alias.
|
|
113
|
+
supervisors: dict[str, dict] = {}
|
|
114
|
+
instances: dict[str, dict] = {}
|
|
115
|
+
|
|
116
|
+
for record in sorted(INSTANCE_DIR.glob("*.json")):
|
|
117
|
+
data = read_json(record)
|
|
118
|
+
if data is None:
|
|
119
|
+
record.unlink(missing_ok=True)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if record.name.endswith(SUPERVISOR_SUFFIX):
|
|
123
|
+
alias = data.get("alias")
|
|
124
|
+
pid = data.get("supervisor_pid")
|
|
125
|
+
entry = {
|
|
126
|
+
"alias": alias,
|
|
127
|
+
"instance_id": alias,
|
|
128
|
+
"config_name": alias,
|
|
129
|
+
"pid": pid,
|
|
130
|
+
# The child is the workload the operator cares about: `status`
|
|
131
|
+
# reports ITS cpu/memory, not the thin supervisor's. `pid` stays
|
|
132
|
+
# the pid of record (the supervisor) — what a stop signals.
|
|
133
|
+
"child_pid": data.get("child_pid"),
|
|
134
|
+
"workload_pid": data.get("child_pid"),
|
|
135
|
+
"source": "supervisor",
|
|
136
|
+
"start_time": data.get("start_time"),
|
|
137
|
+
"instance_file": record,
|
|
138
|
+
}
|
|
139
|
+
bucket = supervisors
|
|
140
|
+
else:
|
|
141
|
+
instance_id = data.get("instance_id", record.stem)
|
|
142
|
+
alias = data.get("alias", instance_id)
|
|
143
|
+
pid = data.get("pid")
|
|
144
|
+
entry = {
|
|
145
|
+
"alias": alias,
|
|
146
|
+
"instance_id": instance_id,
|
|
147
|
+
"config_name": data.get("config_name", "unknown"),
|
|
148
|
+
"pid": pid,
|
|
149
|
+
"child_pid": None,
|
|
150
|
+
"workload_pid": pid,
|
|
151
|
+
"source": "instance",
|
|
152
|
+
"start_time": data.get("start_time"),
|
|
153
|
+
"instance_file": record,
|
|
154
|
+
}
|
|
155
|
+
bucket = instances
|
|
156
|
+
|
|
157
|
+
if not alias or not is_alive(pid):
|
|
158
|
+
# A stale record is not a running instance: remove it so the next
|
|
159
|
+
# `status` does not keep reporting a dead pid.
|
|
160
|
+
record.unlink(missing_ok=True)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
bucket[alias] = entry
|
|
164
|
+
|
|
165
|
+
# Explicit precedence: the supervisor record wins for an alias that has both.
|
|
166
|
+
merged = {**instances, **supervisors}
|
|
167
|
+
return [merged[alias] for alias in sorted(merged)]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The supervisor's Unix-socket adapter for the local seam.
|
|
2
|
+
|
|
3
|
+
The seam's **port** is ``manta_node.seam`` (the node/domain's interface); this
|
|
4
|
+
module is the supervisor's **adapter** that serves it (mechanism §6.4). It is
|
|
5
|
+
infrastructure: it carries a request in and an outcome out, and decides nothing.
|
|
6
|
+
|
|
7
|
+
**Unused ⇒ no behaviour change.** The adapter is only started when the supervisor
|
|
8
|
+
is asked to serve the seam; nothing connects to it until the node relays. That is
|
|
9
|
+
the 2b acceptance: the code can ship, be idle, and change nothing.
|
|
10
|
+
|
|
11
|
+
**Transport.** A Unix domain socket owned by the service user, at
|
|
12
|
+
``~/.manta/supervisor.sock`` (the state dir, never the version tree — NF-6), with
|
|
13
|
+
``0600`` on the socket and its parent directory. Local and same-user only
|
|
14
|
+
(NF-9/NF-10).
|
|
15
|
+
|
|
16
|
+
**What "execute" means here.** At 2b the supervisor has no version tree yet, so
|
|
17
|
+
its executor is a stub: it records the request, idempotently, and answers
|
|
18
|
+
``REQUESTED`` (or ``REFUSED`` for a repeat that is still in flight). The real
|
|
19
|
+
fetch/verify/stage/switch/restart/revert lifecycle is 2c/2d; the seam's contract
|
|
20
|
+
is what this lands.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from ..seam import (
|
|
31
|
+
SeamError,
|
|
32
|
+
SeamState,
|
|
33
|
+
SwitchOutcome,
|
|
34
|
+
UpdateRequest,
|
|
35
|
+
seam_socket_path,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_SOCKET_MODE = 0o600
|
|
39
|
+
_DIR_MODE = 0o700
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SeamServer:
|
|
43
|
+
"""Serves the seam on a Unix socket; asks a handler for each outcome.
|
|
44
|
+
|
|
45
|
+
``handler`` is ``async (UpdateRequest) -> SwitchOutcome`` — the executor. At
|
|
46
|
+
2b it is the stub below; at 2c/2d it becomes the real lifecycle.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, socket_path: Path | None = None) -> None:
|
|
50
|
+
self.socket_path = socket_path or seam_socket_path()
|
|
51
|
+
self._server: asyncio.AbstractServer | None = None
|
|
52
|
+
# Idempotency (NF-5): request_id -> the outcome already answered for it.
|
|
53
|
+
self._answered: dict[str, SwitchOutcome] = {}
|
|
54
|
+
self._in_flight: set[str] = set()
|
|
55
|
+
|
|
56
|
+
async def _read_request(self, reader: asyncio.StreamReader) -> UpdateRequest:
|
|
57
|
+
raw = await reader.readline()
|
|
58
|
+
if not raw:
|
|
59
|
+
raise SeamError("empty request")
|
|
60
|
+
try:
|
|
61
|
+
return UpdateRequest.from_bytes(raw.strip())
|
|
62
|
+
except (ValueError, KeyError) as exc:
|
|
63
|
+
raise SeamError(f"malformed request: {exc}") from exc
|
|
64
|
+
|
|
65
|
+
async def _handle_connection(
|
|
66
|
+
self,
|
|
67
|
+
reader: asyncio.StreamReader,
|
|
68
|
+
writer: asyncio.StreamWriter,
|
|
69
|
+
handler,
|
|
70
|
+
) -> None:
|
|
71
|
+
try:
|
|
72
|
+
request = await self._read_request(reader)
|
|
73
|
+
outcome = await self._answer(request, handler)
|
|
74
|
+
writer.write(outcome.to_bytes() + b"\n")
|
|
75
|
+
await writer.drain()
|
|
76
|
+
except SeamError as exc:
|
|
77
|
+
# A malformed request gets no outcome; the error is the answer.
|
|
78
|
+
writer.write(
|
|
79
|
+
SwitchOutcome(
|
|
80
|
+
request_id="",
|
|
81
|
+
state=SeamState.REFUSED,
|
|
82
|
+
reason=str(exc),
|
|
83
|
+
).to_bytes()
|
|
84
|
+
+ b"\n"
|
|
85
|
+
)
|
|
86
|
+
with contextlib.suppress(ConnectionError):
|
|
87
|
+
await writer.drain()
|
|
88
|
+
finally:
|
|
89
|
+
writer.close()
|
|
90
|
+
with contextlib.suppress(Exception):
|
|
91
|
+
await writer.wait_closed()
|
|
92
|
+
|
|
93
|
+
async def _answer(self, request: UpdateRequest, handler) -> SwitchOutcome:
|
|
94
|
+
# Idempotency: a repeat of a completed request returns the same outcome;
|
|
95
|
+
# a repeat still in flight is refused, not run twice (NF-5).
|
|
96
|
+
if request.request_id in self._answered:
|
|
97
|
+
return self._answered[request.request_id]
|
|
98
|
+
if request.request_id in self._in_flight:
|
|
99
|
+
return SwitchOutcome(
|
|
100
|
+
request_id=request.request_id,
|
|
101
|
+
state=SeamState.REFUSED,
|
|
102
|
+
reason="a request with this request_id is already in flight",
|
|
103
|
+
)
|
|
104
|
+
self._in_flight.add(request.request_id)
|
|
105
|
+
try:
|
|
106
|
+
outcome = await handler(request)
|
|
107
|
+
finally:
|
|
108
|
+
self._in_flight.discard(request.request_id)
|
|
109
|
+
self._answered[request.request_id] = outcome
|
|
110
|
+
return outcome
|
|
111
|
+
|
|
112
|
+
async def start(self, handler) -> None:
|
|
113
|
+
"""Bind the socket (0600, in a 0700 parent) and serve until stopped."""
|
|
114
|
+
parent = self.socket_path.parent
|
|
115
|
+
# `mkdir(mode=...)` only applies the mode when it CREATES the directory.
|
|
116
|
+
# `~/.manta` is usually created earlier by the config/log paths at the
|
|
117
|
+
# default umask, so chmod it explicitly: the socket's parent must be 0700
|
|
118
|
+
# even when it already existed (NF-9/NF-10).
|
|
119
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
os.chmod(parent, _DIR_MODE)
|
|
121
|
+
# A stale socket from a previous supervisor would make `bind` fail; it is
|
|
122
|
+
# ours to remove (same user, same path), and nothing else listens on it.
|
|
123
|
+
self.socket_path.unlink(missing_ok=True)
|
|
124
|
+
# The parent is 0700 before `bind`, so no non-owner can even reach the
|
|
125
|
+
# socket path; `chmod` after the bind then asserts the socket's own 0600
|
|
126
|
+
# (NF-9). No umask is set: it is process-wide, and `start_unix_server`
|
|
127
|
+
# yields before returning, so a tightened mask would leak across the
|
|
128
|
+
# await to any concurrent file creation.
|
|
129
|
+
self._server = await asyncio.start_unix_server(
|
|
130
|
+
lambda r, w: self._handle_connection(r, w, handler),
|
|
131
|
+
path=str(self.socket_path),
|
|
132
|
+
)
|
|
133
|
+
os.chmod(self.socket_path, _SOCKET_MODE)
|
|
134
|
+
async with self._server:
|
|
135
|
+
await self._server.serve_forever()
|
|
136
|
+
|
|
137
|
+
async def stop(self) -> None:
|
|
138
|
+
if self._server is not None:
|
|
139
|
+
self._server.close()
|
|
140
|
+
with contextlib.suppress(Exception):
|
|
141
|
+
await self._server.wait_closed()
|
|
142
|
+
self._server = None
|
|
143
|
+
self.socket_path.unlink(missing_ok=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def stub_handler(request: UpdateRequest) -> SwitchOutcome:
|
|
147
|
+
"""The 2b executor: accept and record; the lifecycle is 2c/2d.
|
|
148
|
+
|
|
149
|
+
It makes no decision about versions — it answers ``REQUESTED`` so the seam's
|
|
150
|
+
contract is exercised end to end without a version tree.
|
|
151
|
+
"""
|
|
152
|
+
return SwitchOutcome(
|
|
153
|
+
request_id=request.request_id,
|
|
154
|
+
state=SeamState.REQUESTED,
|
|
155
|
+
reason="accepted; the switch lifecycle lands in 2c/2d",
|
|
156
|
+
detail={"target": request.target, "force": request.force},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SocketSeamClient:
|
|
161
|
+
"""The port's adapter over the supervisor's Unix socket (the node's side).
|
|
162
|
+
|
|
163
|
+
Implements ``manta_node.seam.SeamClient``. A connect failure raises
|
|
164
|
+
``SeamError``: the node reports it and mutates nothing (NF-3).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, socket_path: Path | None = None) -> None:
|
|
168
|
+
self.socket_path = socket_path or seam_socket_path()
|
|
169
|
+
|
|
170
|
+
async def request_update(self, request: UpdateRequest) -> SwitchOutcome:
|
|
171
|
+
try:
|
|
172
|
+
reader, writer = await asyncio.open_unix_connection(str(self.socket_path))
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise SeamError(f"seam unreachable at {self.socket_path}: {exc}") from exc
|
|
175
|
+
try:
|
|
176
|
+
writer.write(request.to_bytes() + b"\n")
|
|
177
|
+
await writer.drain()
|
|
178
|
+
raw = await reader.readline()
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise SeamError(f"seam failed mid-request: {exc}") from exc
|
|
181
|
+
finally:
|
|
182
|
+
writer.close()
|
|
183
|
+
with contextlib.suppress(Exception):
|
|
184
|
+
await writer.wait_closed()
|
|
185
|
+
if not raw:
|
|
186
|
+
raise SeamError("seam closed without an outcome")
|
|
187
|
+
try:
|
|
188
|
+
return SwitchOutcome.from_bytes(raw.strip())
|
|
189
|
+
except (ValueError, KeyError) as exc:
|
|
190
|
+
raise SeamError(f"malformed outcome: {exc}") from exc
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: manta_node
|
|
3
|
-
Version: 0.6b5.
|
|
3
|
+
Version: 0.6b5.dev702
|
|
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,11 +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=K2UZAk-FHs1ERrwf5hTcrQC2AASG2J2eVlr-l45aDt4,28066
|
|
4
|
+
manta_node/seam.py,sha256=wAI3uL1iuV6pmDOaBW7WZi7_V7-NsHQDP6C_gFIjCiA,4747
|
|
4
5
|
manta_node/task_manager.py,sha256=xRE-yKdUW0FFR8vtX1jDwl1AxTbqVD_JS7QVR62GA00,38002
|
|
5
6
|
manta_node/tasks.py,sha256=o837Hi-KFVJg9LF5iOtE8oD6UuTOfrEPWR-JOdhRGeY,6983
|
|
6
7
|
manta_node/utils.py,sha256=Hw4Iw2jYQYsfVLVJuBMt-_rMFBeml3nCDE2jiiXc8YM,1155
|
|
7
8
|
manta_node/cli/__init__.py,sha256=arzO3mXDJ-E2yeeytbZfN7mCswwxdQ43_QjyeL2E5yE,134
|
|
8
|
-
manta_node/cli/_version_generated.py,sha256=
|
|
9
|
+
manta_node/cli/_version_generated.py,sha256=lUZps58EgIuYIAwnIQz2-ijOxuSzZ_io_eQmog7Eujc,275
|
|
9
10
|
manta_node/cli/config_manager.py,sha256=BCju9zqzraviL3KrzRnB-vZQUY__Q5kuEqb7EdB8YdI,4409
|
|
10
11
|
manta_node/cli/main.py,sha256=dZa6zrypWBvpN8hd4qpxevvlyNFhxKINCg1537NJ36w,4318
|
|
11
12
|
manta_node/cli/version.py,sha256=3lrnIWpOosszBN-VsCBVLxDgpvCC7IB0aDIKx_3e7hI,3115
|
|
@@ -15,8 +16,8 @@ manta_node/cli/commands/config.py,sha256=BiarCD0S8rcNYbio4RLzDvUS7A50s5dd5F4TiZq
|
|
|
15
16
|
manta_node/cli/commands/logs.py,sha256=cXOSdwhDIJAiKMmJYdzrnpapIxudxVdMzYT-KAWDKVk,5259
|
|
16
17
|
manta_node/cli/commands/network.py,sha256=QqncD6vbNlg65ptQdFYexEUMALQm8UwcftbW3sfQrjw,42070
|
|
17
18
|
manta_node/cli/commands/start.py,sha256=HuIovoL3a1yQuEIIZMmvRUu3mq1cKJlFZgBf-U2zS7A,25637
|
|
18
|
-
manta_node/cli/commands/status.py,sha256=
|
|
19
|
-
manta_node/cli/commands/stop.py,sha256=
|
|
19
|
+
manta_node/cli/commands/status.py,sha256=I3z4bee0uqobEhA4akJ3OErRJ7_UcieW6tvLP3pyZ-A,5608
|
|
20
|
+
manta_node/cli/commands/stop.py,sha256=bGsvDrTiIvDN20HMsXKQYg83iWRaAnhbJ8xcalJwre0,9117
|
|
20
21
|
manta_node/domain/__init__.py,sha256=uyJXDf0QgfGCsXLMggqKCB5QV9oneYJ4iM-REaJGXDM,217
|
|
21
22
|
manta_node/domain/task_lifecycle.py,sha256=CmaK6OL4KNSy81YYosINduVQQW3LjycAwmfpRmXrldk,3323
|
|
22
23
|
manta_node/domain/value_objects/__init__.py,sha256=Sut2I8iSNNYBtI42-tfKXJ8aod2HV_k_N_uM_KWwjqs,135
|
|
@@ -40,7 +41,7 @@ manta_node/infrastructure/metrics/__init__.py,sha256=ODGAj9s63F5jba2c3dQRMfNK6I4
|
|
|
40
41
|
manta_node/infrastructure/metrics/collector.py,sha256=9ESw__Pc2tV6ldG5yhos2IS7T5Td59KxTIwDRZc_G4w,3067
|
|
41
42
|
manta_node/infrastructure/metrics/metrics_collector.py,sha256=TI_9tRoIUunGx_BstVavK6VElig7Wc5mJ0kB4bFumVs,11048
|
|
42
43
|
manta_node/infrastructure/mqtt/__init__.py,sha256=riXo1sXgO4YV6DHCmWRqK61BAmuGLdHiVdQFunAJadA,140
|
|
43
|
-
manta_node/infrastructure/mqtt/command_handler.py,sha256=
|
|
44
|
+
manta_node/infrastructure/mqtt/command_handler.py,sha256=Ym8t0fA-qWjg8n0VDyYJYxCqulVH3lOJB1MjQHu5iZE,28110
|
|
44
45
|
manta_node/infrastructure/network/__init__.py,sha256=On3XLgycFQoc-fBCa5hFuFDfEJjmFy7Wl2CK3D2LkdI,478
|
|
45
46
|
manta_node/infrastructure/network/event_registry.py,sha256=iWEfUJRzDnWjOrDinpYqVyIUpGKJjp7RZkjMDPfpplM,1661
|
|
46
47
|
manta_node/infrastructure/network/factory.py,sha256=-LvHbBp5nBpQ7fyQT1T0pvK3aczZqWc9QNDW37brBeU,3897
|
|
@@ -51,9 +52,13 @@ manta_node/infrastructure/process/executor.py,sha256=Ab1XvNuelEGCD5KdZLgYYnpjYZ5
|
|
|
51
52
|
manta_node/infrastructure/security/__init__.py,sha256=lI6vkbUmfmt0m363iO4HzuzVNoQAc7Clp2R5vAhZbgE,64
|
|
52
53
|
manta_node/infrastructure/security/auth_agent.py,sha256=WkPTg_g0gix7xomb_on_Ft3TNX0FpeqS7gwgJW9mTvk,11037
|
|
53
54
|
manta_node/infrastructure/security/token_provider.py,sha256=u3bcbQYNBskFzunO-wOSZpGkDN_CU1BCB5HUbZTC4Vg,1832
|
|
54
|
-
manta_node
|
|
55
|
-
manta_node
|
|
56
|
-
manta_node
|
|
57
|
-
manta_node
|
|
58
|
-
manta_node-0.6b5.
|
|
59
|
-
manta_node-0.6b5.
|
|
55
|
+
manta_node/supervisor/__init__.py,sha256=kgdoNce1tM4e7jvdxkdmnNJV1L92-NI2cRfYSdm9vsw,542
|
|
56
|
+
manta_node/supervisor/process.py,sha256=is3phcIKF9AdRORqXK7_rwiiqCF_4t4txXu-vGtUOE8,8655
|
|
57
|
+
manta_node/supervisor/records.py,sha256=fUlL1SfmWpGnCbJ9956cuy0H3a7-aEF-B-naqbJiYoU,6405
|
|
58
|
+
manta_node/supervisor/seam.py,sha256=zafKSWFeZClprftM0tYaTW3Ng7lxeFcndRIHplpSnf4,7708
|
|
59
|
+
manta_node-0.6b5.dev702.dist-info/licenses/LICENSE,sha256=9mBgLhiB9goQPQjg9XQJ6FjgVxYw9Q8lyOZYkPGwF_c,34484
|
|
60
|
+
manta_node-0.6b5.dev702.dist-info/METADATA,sha256=5JY-PMYelVoodkp4dK-vs1D-lNTNWd-lHT0NzZ_6tXE,4231
|
|
61
|
+
manta_node-0.6b5.dev702.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
62
|
+
manta_node-0.6b5.dev702.dist-info/entry_points.txt,sha256=uCwggRApalLEsYs1YxwTBN0-e9oMJAkMoBp01HaSdoY,110
|
|
63
|
+
manta_node-0.6b5.dev702.dist-info/top_level.txt,sha256=nbT5J5WETuXM9UPoQckl29uUOq8wMYdRb9QDx5kAFeo,11
|
|
64
|
+
manta_node-0.6b5.dev702.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|