wnm 0.0.9__py3-none-any.whl → 0.0.10__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.

Potentially problematic release.


This version of wnm might be problematic. Click here for more details.

@@ -0,0 +1,340 @@
1
+ """
2
+ SetsidManager: Manage nodes as background processes using setsid.
3
+
4
+ This manager runs nodes as simple background processes without requiring
5
+ sudo privileges or systemd. Suitable for development and non-systemd environments.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import signal
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import psutil
17
+
18
+ from wnm.common import DEAD, RESTARTING, RUNNING, STOPPED, UPGRADING
19
+ from wnm.config import BOOTSTRAP_CACHE_DIR
20
+ from wnm.models import Node
21
+ from wnm.process_managers.base import NodeProcess, ProcessManager
22
+
23
+
24
+ class SetsidManager(ProcessManager):
25
+ """Manage nodes as background processes via setsid"""
26
+
27
+ def __init__(self, session_factory=None, firewall_type: str = None):
28
+ """
29
+ Initialize SetsidManager.
30
+
31
+ Args:
32
+ session_factory: SQLAlchemy session factory (optional, for status updates)
33
+ firewall_type: Type of firewall to use (defaults to auto-detect)
34
+ """
35
+ super().__init__(firewall_type)
36
+ self.S = session_factory
37
+
38
+ def _get_pid_file(self, node: Node) -> Path:
39
+ """Get path to PID file for a node"""
40
+ return Path(node.root_dir) / "node.pid"
41
+
42
+ def _write_pid_file(self, node: Node, pid: int):
43
+ """Write PID to file"""
44
+ pid_file = self._get_pid_file(node)
45
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
46
+ pid_file.write_text(str(pid))
47
+
48
+ def _read_pid_file(self, node: Node) -> int | None:
49
+ """Read PID from file, returns None if file doesn't exist or is invalid"""
50
+ pid_file = self._get_pid_file(node)
51
+ try:
52
+ if pid_file.exists():
53
+ pid = int(pid_file.read_text().strip())
54
+ # Verify process exists
55
+ if psutil.pid_exists(pid):
56
+ return pid
57
+ # Stale PID file, remove it
58
+ pid_file.unlink()
59
+ except (ValueError, OSError) as e:
60
+ logging.debug(f"Failed to read PID file {pid_file}: {e}")
61
+ return None
62
+
63
+ def create_node(self, node: Node, binary_path: str) -> bool:
64
+ """
65
+ Create and start a new node as a background process.
66
+
67
+ Args:
68
+ node: Node database record with configuration
69
+ binary_path: Path to the antnode binary
70
+
71
+ Returns:
72
+ True if node was created successfully
73
+ """
74
+ logging.info(f"Creating setsid node {node.id}")
75
+
76
+ # Create directories
77
+ node_dir = Path(node.root_dir)
78
+ log_dir = node_dir / "logs"
79
+
80
+ try:
81
+ node_dir.mkdir(parents=True, exist_ok=True)
82
+ log_dir.mkdir(parents=True, exist_ok=True)
83
+ except OSError as err:
84
+ logging.error(f"Failed to create directories: {err}")
85
+ return False
86
+
87
+ # Copy binary to node directory
88
+ binary_dest = node_dir / "antnode"
89
+ try:
90
+ shutil.copy2(binary_path, binary_dest)
91
+ binary_dest.chmod(0o755)
92
+ except (OSError, shutil.Error) as err:
93
+ logging.error(f"Failed to copy binary: {err}")
94
+ return False
95
+
96
+ # Start the node
97
+ return self.start_node(node)
98
+
99
+ def start_node(self, node: Node) -> bool:
100
+ """
101
+ Start a node as a background process.
102
+
103
+ Args:
104
+ node: Node database record
105
+
106
+ Returns:
107
+ True if node started successfully
108
+ """
109
+ logging.info(f"Starting setsid node {node.id}")
110
+
111
+ # Check if already running
112
+ if self._read_pid_file(node):
113
+ logging.warning(f"Node {node.id} already running")
114
+ return True
115
+
116
+ # Prepare command
117
+ binary = Path(node.root_dir) / "antnode"
118
+ if not binary.exists():
119
+ logging.error(f"Binary not found: {binary}")
120
+ return False
121
+
122
+ log_dir = Path(node.root_dir) / "logs"
123
+
124
+ cmd = [
125
+ str(binary),
126
+ "--bootstrap-cache-dir",
127
+ BOOTSTRAP_CACHE_DIR,
128
+ "--root-dir",
129
+ node.root_dir,
130
+ "--port",
131
+ str(node.port),
132
+ "--enable-metrics-server",
133
+ "--metrics-server-port",
134
+ str(node.metrics_port),
135
+ "--log-output-dest",
136
+ str(log_dir),
137
+ "--max-log-files",
138
+ "1",
139
+ "--max-archived-log-files",
140
+ "1",
141
+ "--rewards-address",
142
+ node.wallet,
143
+ node.network,
144
+ ]
145
+
146
+ # Start process in background using setsid
147
+ try:
148
+ # Use setsid to detach from terminal
149
+ process = subprocess.Popen(
150
+ ["setsid"] + cmd,
151
+ stdout=subprocess.DEVNULL,
152
+ stderr=subprocess.DEVNULL,
153
+ stdin=subprocess.DEVNULL,
154
+ start_new_session=True,
155
+ env={
156
+ **os.environ,
157
+ **({"CUSTOM_ENV": node.environment} if node.environment else {}),
158
+ },
159
+ )
160
+
161
+ # Give process a moment to start
162
+ time.sleep(0.5)
163
+
164
+ # Check if process started successfully
165
+ if process.poll() is not None:
166
+ logging.error(
167
+ f"Process exited immediately with code {process.returncode}"
168
+ )
169
+ return False
170
+
171
+ # Get actual PID of the antnode process (not setsid wrapper)
172
+ # The setsid process becomes the session leader, we need to find the child
173
+ time.sleep(1) # Give process time to spawn
174
+
175
+ # Find the antnode process by command line
176
+ for proc in psutil.process_iter(["pid", "cmdline"]):
177
+ try:
178
+ cmdline = proc.info["cmdline"]
179
+ if (
180
+ cmdline
181
+ and "antnode" in cmdline[0]
182
+ and str(node.port) in " ".join(cmdline)
183
+ ):
184
+ pid = proc.info["pid"]
185
+ self._write_pid_file(node, pid)
186
+ logging.info(f"Node {node.id} started with PID {pid}")
187
+ break
188
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
189
+ continue
190
+ else:
191
+ logging.warning(
192
+ f"Could not find PID for node {node.id}, using setsid PID"
193
+ )
194
+ self._write_pid_file(node, process.pid)
195
+
196
+ except (subprocess.SubprocessError, OSError) as err:
197
+ logging.error(f"Failed to start node: {err}")
198
+ return False
199
+
200
+ # Open firewall port (best effort, may fail without sudo)
201
+ self.enable_firewall_port(node.port)
202
+
203
+ return True
204
+
205
+ def stop_node(self, node: Node) -> bool:
206
+ """
207
+ Stop a node process.
208
+
209
+ Args:
210
+ node: Node database record
211
+
212
+ Returns:
213
+ True if node stopped successfully
214
+ """
215
+ logging.info(f"Stopping setsid node {node.id}")
216
+
217
+ pid = self._read_pid_file(node)
218
+ if not pid:
219
+ logging.warning(f"No PID found for node {node.id}")
220
+ return True
221
+
222
+ try:
223
+ # Try graceful shutdown first
224
+ process = psutil.Process(pid)
225
+ process.terminate()
226
+
227
+ # Wait up to 10 seconds for graceful shutdown
228
+ try:
229
+ process.wait(timeout=10)
230
+ except psutil.TimeoutExpired:
231
+ logging.warning(f"Node {node.id} did not terminate gracefully, killing")
232
+ process.kill()
233
+ process.wait(timeout=5)
234
+
235
+ # Remove PID file
236
+ pid_file = self._get_pid_file(node)
237
+ if pid_file.exists():
238
+ pid_file.unlink()
239
+
240
+ except psutil.NoSuchProcess:
241
+ logging.debug(f"Process {pid} already terminated")
242
+ except psutil.AccessDenied as err:
243
+ logging.error(f"Access denied stopping process {pid}: {err}")
244
+ return False
245
+ except Exception as err:
246
+ logging.error(f"Failed to stop node: {err}")
247
+ return False
248
+
249
+ # Close firewall port
250
+ self.disable_firewall_port(node.port)
251
+
252
+ return True
253
+
254
+ def restart_node(self, node: Node) -> bool:
255
+ """
256
+ Restart a node.
257
+
258
+ Args:
259
+ node: Node database record
260
+
261
+ Returns:
262
+ True if node restarted successfully
263
+ """
264
+ logging.info(f"Restarting setsid node {node.id}")
265
+
266
+ self.stop_node(node)
267
+ time.sleep(1) # Brief pause between stop and start
268
+ return self.start_node(node)
269
+
270
+ def get_status(self, node: Node) -> NodeProcess:
271
+ """
272
+ Get current status of a node.
273
+
274
+ Args:
275
+ node: Node database record
276
+
277
+ Returns:
278
+ NodeProcess with current status
279
+ """
280
+ pid = self._read_pid_file(node)
281
+
282
+ # Check if root directory exists
283
+ if not os.path.isdir(node.root_dir):
284
+ return NodeProcess(node_id=node.id, pid=None, status=DEAD)
285
+
286
+ if pid:
287
+ try:
288
+ process = psutil.Process(pid)
289
+ # Verify it's actually our node process
290
+ if process.is_running():
291
+ return NodeProcess(node_id=node.id, pid=pid, status=RUNNING)
292
+ except psutil.NoSuchProcess:
293
+ pass
294
+
295
+ return NodeProcess(node_id=node.id, pid=None, status=STOPPED)
296
+
297
+ def remove_node(self, node: Node) -> bool:
298
+ """
299
+ Stop and remove a node.
300
+
301
+ Args:
302
+ node: Node database record
303
+
304
+ Returns:
305
+ True if node was removed successfully
306
+ """
307
+ logging.info(f"Removing setsid node {node.id}")
308
+
309
+ # Stop the node first
310
+ self.stop_node(node)
311
+
312
+ # Remove node directory
313
+ try:
314
+ node_dir = Path(node.root_dir)
315
+ if node_dir.exists():
316
+ shutil.rmtree(node_dir)
317
+ except (OSError, shutil.Error) as err:
318
+ logging.error(f"Failed to remove node directory: {err}")
319
+ return False
320
+
321
+ return True
322
+
323
+ def survey_nodes(self, machine_config) -> list:
324
+ """
325
+ Survey all setsid-managed antnode processes.
326
+
327
+ Setsid nodes are typically not used for migration scenarios.
328
+ This returns an empty list as setsid processes are created
329
+ fresh by WNM and don't pre-exist.
330
+
331
+ Args:
332
+ machine_config: Machine configuration object
333
+
334
+ Returns:
335
+ Empty list (setsid nodes don't pre-exist for migration)
336
+ """
337
+ logging.info(
338
+ "Setsid survey not implemented (setsid nodes created fresh by WNM)"
339
+ )
340
+ return []