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,592 @@
1
+ """
2
+ LaunchctlManager: Manage nodes via macOS launchd services.
3
+
4
+ Handles node lifecycle operations using launchd plist files and launchctl commands.
5
+ Designed for user-level node management (no sudo required).
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import plistlib
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import time
15
+ from typing import Optional
16
+
17
+ from wnm.common import DEAD, RESTARTING, RUNNING, STOPPED, UPGRADING
18
+ from wnm.config import BOOTSTRAP_CACHE_DIR, LOG_DIR
19
+ from wnm.models import Node
20
+ from wnm.process_managers.base import NodeProcess, ProcessManager
21
+ from wnm.utils import (
22
+ get_antnode_version,
23
+ get_node_age,
24
+ read_node_metadata,
25
+ read_node_metrics,
26
+ )
27
+
28
+
29
+ class LaunchctlManager(ProcessManager):
30
+ """Manage nodes as launchd user agents on macOS"""
31
+
32
+ def __init__(self, session_factory=None, firewall_type: str = None):
33
+ """
34
+ Initialize LaunchctlManager.
35
+
36
+ Args:
37
+ session_factory: SQLAlchemy session factory (optional, for status updates)
38
+ firewall_type: Type of firewall to use (defaults to "null" on macOS)
39
+ """
40
+ super().__init__(firewall_type)
41
+ self.S = session_factory
42
+ self.plist_dir = os.path.expanduser("~/Library/LaunchAgents")
43
+
44
+ # Ensure plist directory exists
45
+ os.makedirs(self.plist_dir, exist_ok=True)
46
+
47
+ def _get_plist_path(self, node: Node) -> str:
48
+ """Get the path to the plist file for a node."""
49
+ label = self._get_service_label(node)
50
+ return os.path.join(self.plist_dir, f"{label}.plist")
51
+
52
+ def _get_service_label(self, node: Node) -> str:
53
+ """Get the launchd service label for a node."""
54
+ return f"com.autonomi.antnode-{node.id}"
55
+
56
+ def _get_service_domain(self) -> str:
57
+ """Get the launchd domain for user agents."""
58
+ # User agents run under gui/<uid>/ domain
59
+ uid = os.getuid()
60
+ return f"gui/{uid}"
61
+
62
+ def _generate_plist_content(self, node: Node, binary_path: str) -> str:
63
+ """
64
+ Generate the plist XML content for a node.
65
+
66
+ Args:
67
+ node: Node database record
68
+ binary_path: Path to the node binary in the node's directory
69
+
70
+ Returns:
71
+ XML string for the plist file
72
+ """
73
+ label = self._get_service_label(node)
74
+ log_file = os.path.join(LOG_DIR, f"antnode{node.node_name}.log")
75
+
76
+ # Build program arguments array
77
+ args = [
78
+ binary_path,
79
+ "--no-upnp", # Disable UPnP (not needed/available on macOS in many cases)
80
+ "--bootstrap-cache-dir",
81
+ BOOTSTRAP_CACHE_DIR,
82
+ "--root-dir",
83
+ node.root_dir,
84
+ "--port",
85
+ str(node.port),
86
+ "--metrics-server-port",
87
+ str(node.metrics_port),
88
+ "--log-output-dest",
89
+ LOG_DIR,
90
+ "--max-log-files",
91
+ "1",
92
+ "--max-archived-log-files",
93
+ "1",
94
+ "--rewards-address",
95
+ node.wallet,
96
+ node.network,
97
+ ]
98
+
99
+ # Build ProgramArguments XML
100
+ args_xml = "\n".join(f" <string>{arg}</string>" for arg in args)
101
+
102
+ # Build environment variables if needed
103
+ env_xml = ""
104
+ if node.environment:
105
+ # Parse environment variables from node.environment string
106
+ # Expected format: "KEY1=value1 KEY2=value2"
107
+ env_vars = {}
108
+ for pair in node.environment.split():
109
+ if "=" in pair:
110
+ key, value = pair.split("=", 1)
111
+ env_vars[key] = value
112
+
113
+ if env_vars:
114
+ env_xml = " <key>EnvironmentVariables</key>\n <dict>\n"
115
+ for key, value in env_vars.items():
116
+ env_xml += f" <key>{key}</key>\n"
117
+ env_xml += f" <string>{value}</string>\n"
118
+ env_xml += " </dict>\n"
119
+
120
+ plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
121
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
122
+ <plist version="1.0">
123
+ <dict>
124
+ <key>Label</key>
125
+ <string>{label}</string>
126
+
127
+ <key>ProgramArguments</key>
128
+ <array>
129
+ {args_xml}
130
+ </array>
131
+
132
+ <key>WorkingDirectory</key>
133
+ <string>{node.root_dir}</string>
134
+
135
+ <key>StandardOutPath</key>
136
+ <string>{log_file}</string>
137
+
138
+ <key>StandardErrorPath</key>
139
+ <string>{log_file}</string>
140
+
141
+ <key>RunAtLoad</key>
142
+ <true/>
143
+
144
+ <key>KeepAlive</key>
145
+ <true/>
146
+
147
+ {env_xml}</dict>
148
+ </plist>
149
+ """
150
+ return plist_content
151
+
152
+ def create_node(self, node: Node, binary_path: str) -> bool:
153
+ """
154
+ Create and start a new node as a launchd user agent.
155
+
156
+ Args:
157
+ node: Node database record with configuration
158
+ binary_path: Path to the antnode binary (typically ~/.local/bin/antnode)
159
+
160
+ Returns:
161
+ True if node was created successfully
162
+ """
163
+ logging.info(f"Creating launchd node {node.id}")
164
+
165
+ # Create node directories
166
+ log_dir = os.path.join(LOG_DIR, f"antnode{node.node_name}")
167
+ try:
168
+ os.makedirs(node.root_dir, exist_ok=True)
169
+ os.makedirs(log_dir, exist_ok=True)
170
+ except OSError as err:
171
+ logging.error(f"Failed to create directories: {err}")
172
+ return False
173
+
174
+ # Copy binary to node directory (each node gets its own copy)
175
+ node_binary_path = os.path.join(node.root_dir, "antnode")
176
+ try:
177
+ if not os.path.exists(binary_path):
178
+ logging.error(f"Source binary not found: {binary_path}")
179
+ return False
180
+ shutil.copy2(binary_path, node_binary_path)
181
+ # Make it executable
182
+ os.chmod(node_binary_path, 0o755)
183
+ except (OSError, shutil.Error) as err:
184
+ logging.error(f"Failed to copy binary: {err}")
185
+ return False
186
+
187
+ # Generate plist file
188
+ plist_path = self._get_plist_path(node)
189
+ plist_content = self._generate_plist_content(node, node_binary_path)
190
+
191
+ try:
192
+ with open(plist_path, "w") as f:
193
+ f.write(plist_content)
194
+ except OSError as err:
195
+ logging.error(f"Failed to write plist file: {err}")
196
+ return False
197
+
198
+ # Load the service with launchctl
199
+ try:
200
+ result = subprocess.run(
201
+ ["launchctl", "load", plist_path],
202
+ capture_output=True,
203
+ text=True,
204
+ check=True,
205
+ )
206
+ logging.info(f"Loaded launchd service: {self._get_service_label(node)}")
207
+ except subprocess.CalledProcessError as err:
208
+ logging.error(f"Failed to load service: {err.stderr}")
209
+ return False
210
+
211
+ # Enable firewall port
212
+ if not self.enable_firewall_port(
213
+ node.port, protocol="udp", comment=f"antnode{node.node_name}"
214
+ ):
215
+ logging.warning(f"Failed to enable firewall for port {node.port}")
216
+
217
+ return True
218
+
219
+ def start_node(self, node: Node) -> bool:
220
+ """
221
+ Start a stopped node.
222
+
223
+ Args:
224
+ node: Node database record
225
+
226
+ Returns:
227
+ True if node started successfully
228
+ """
229
+ logging.info(f"Starting launchd node {node.id}")
230
+
231
+ plist_path = self._get_plist_path(node)
232
+ if not os.path.exists(plist_path):
233
+ logging.error(f"Plist file not found: {plist_path}")
234
+ return False
235
+
236
+ try:
237
+ subprocess.run(
238
+ ["launchctl", "load", plist_path],
239
+ capture_output=True,
240
+ text=True,
241
+ check=True,
242
+ )
243
+ return True
244
+ except subprocess.CalledProcessError as err:
245
+ logging.error(f"Failed to start node: {err.stderr}")
246
+ return False
247
+
248
+ def stop_node(self, node: Node) -> bool:
249
+ """
250
+ Stop a running node.
251
+
252
+ Args:
253
+ node: Node database record
254
+
255
+ Returns:
256
+ True if node stopped successfully
257
+ """
258
+ logging.info(f"Stopping launchd node {node.id}")
259
+
260
+ plist_path = self._get_plist_path(node)
261
+ if not os.path.exists(plist_path):
262
+ logging.warning(f"Plist file not found: {plist_path}")
263
+ # If plist doesn't exist, consider it already stopped
264
+ return True
265
+
266
+ try:
267
+ subprocess.run(
268
+ ["launchctl", "unload", plist_path],
269
+ capture_output=True,
270
+ text=True,
271
+ check=True,
272
+ )
273
+ return True
274
+ except subprocess.CalledProcessError as err:
275
+ # It's possible the service is already unloaded
276
+ if "Could not find specified service" in err.stderr:
277
+ logging.info(
278
+ f"Service already unloaded: {self._get_service_label(node)}"
279
+ )
280
+ return True
281
+ logging.error(f"Failed to stop node: {err.stderr}")
282
+ return False
283
+
284
+ def restart_node(self, node: Node) -> bool:
285
+ """
286
+ Restart a node using launchctl kickstart.
287
+
288
+ Args:
289
+ node: Node database record
290
+
291
+ Returns:
292
+ True if node restarted successfully
293
+ """
294
+ logging.info(f"Restarting launchd node {node.id}")
295
+
296
+ label = self._get_service_label(node)
297
+ domain = self._get_service_domain()
298
+ service_target = f"{domain}/{label}"
299
+
300
+ try:
301
+ # Use kickstart -k to kill and restart
302
+ subprocess.run(
303
+ ["launchctl", "kickstart", "-k", service_target],
304
+ capture_output=True,
305
+ text=True,
306
+ check=True,
307
+ )
308
+ return True
309
+ except subprocess.CalledProcessError as err:
310
+ logging.error(f"Failed to restart node: {err.stderr}")
311
+ # Fallback: try unload/load
312
+ if self.stop_node(node):
313
+ return self.start_node(node)
314
+ return False
315
+
316
+ def get_status(self, node: Node) -> NodeProcess:
317
+ """
318
+ Get current runtime status of a node.
319
+
320
+ Args:
321
+ node: Node database record
322
+
323
+ Returns:
324
+ NodeProcess with current status and PID
325
+ """
326
+ label = self._get_service_label(node)
327
+
328
+ try:
329
+ result = subprocess.run(
330
+ ["launchctl", "list", label],
331
+ capture_output=True,
332
+ text=True,
333
+ check=False, # Don't raise exception - service might not exist
334
+ )
335
+
336
+ if result.returncode != 0:
337
+ # Service not found
338
+ return NodeProcess(node_id=node.id, pid=None, status=STOPPED)
339
+
340
+ # Parse launchctl list output
341
+ # Format:
342
+ # {
343
+ # "Label" = "com.autonomi.antnode-1";
344
+ # "LimitLoadToSessionType" = "Aqua";
345
+ # "OnDemand" = false;
346
+ # "LastExitStatus" = 0;
347
+ # "PID" = 12345;
348
+ # "Program" = "/path/to/binary";
349
+ # };
350
+
351
+ pid = None
352
+ last_exit_status = None
353
+
354
+ for line in result.stdout.split("\n"):
355
+ if '"PID"' in line:
356
+ match = re.search(r'"PID"\s*=\s*(\d+)', line)
357
+ if match:
358
+ pid = int(match.group(1))
359
+ elif '"LastExitStatus"' in line:
360
+ match = re.search(r'"LastExitStatus"\s*=\s*(-?\d+)', line)
361
+ if match:
362
+ last_exit_status = int(match.group(1))
363
+
364
+ # Determine status
365
+ if pid is not None:
366
+ status = RUNNING
367
+ elif last_exit_status == 0:
368
+ status = STOPPED
369
+ else:
370
+ status = DEAD # Crashed
371
+
372
+ return NodeProcess(node_id=node.id, pid=pid, status=status)
373
+
374
+ except Exception as err:
375
+ logging.error(f"Failed to get status for node {node.id}: {err}")
376
+ return NodeProcess(node_id=node.id, pid=None, status=STOPPED)
377
+
378
+ def remove_node(self, node: Node) -> bool:
379
+ """
380
+ Stop and remove all traces of a node.
381
+
382
+ Args:
383
+ node: Node database record
384
+
385
+ Returns:
386
+ True if node was removed successfully
387
+ """
388
+ logging.info(f"Removing launchd node {node.id}")
389
+
390
+ # Stop the node first
391
+ self.stop_node(node)
392
+
393
+ # Remove plist file
394
+ plist_path = self._get_plist_path(node)
395
+ try:
396
+ if os.path.exists(plist_path):
397
+ os.remove(plist_path)
398
+ except OSError as err:
399
+ logging.error(f"Failed to remove plist file: {err}")
400
+ return False
401
+
402
+ # Remove node directories
403
+ try:
404
+ if os.path.exists(node.root_dir):
405
+ shutil.rmtree(node.root_dir)
406
+ except OSError as err:
407
+ logging.error(f"Failed to remove node directory: {err}")
408
+ return False
409
+
410
+ # Remove log directory
411
+ log_dir = os.path.join(LOG_DIR, f"antnode{node.node_name}")
412
+ try:
413
+ if os.path.exists(log_dir):
414
+ shutil.rmtree(log_dir)
415
+ except OSError as err:
416
+ logging.warning(f"Failed to remove log directory: {err}")
417
+ # Non-fatal
418
+
419
+ # Disable firewall port
420
+ if not self.disable_firewall_port(node.port):
421
+ logging.warning(f"Failed to disable firewall for port {node.port}")
422
+
423
+ return True
424
+
425
+ def survey_nodes(self, machine_config) -> list:
426
+ """
427
+ Survey all launchd-managed antnode services.
428
+
429
+ Scans ~/Library/LaunchAgents for com.autonomi.antnode-*.plist files
430
+ and collects their configuration and current status.
431
+
432
+ Args:
433
+ machine_config: Machine configuration object
434
+
435
+ Returns:
436
+ List of node dictionaries ready for database insertion
437
+ """
438
+ plist_dir = self.plist_dir
439
+ plist_names = []
440
+
441
+ # Scan for antnode plist files
442
+ if os.path.exists(plist_dir):
443
+ try:
444
+ for file in os.listdir(plist_dir):
445
+ if re.match(r"com\.autonomi\.antnode-\d+\.plist", file):
446
+ plist_names.append(file)
447
+ except PermissionError as e:
448
+ logging.error(f"Permission denied reading {plist_dir}: {e}")
449
+ return []
450
+ except Exception as e:
451
+ logging.error(f"Error listing launchd plists: {e}")
452
+ return []
453
+
454
+ if not plist_names:
455
+ logging.info("No launchd antnode services found")
456
+ return []
457
+
458
+ logging.info(f"Found {len(plist_names)} launchd services to survey")
459
+
460
+ details = []
461
+ for plist_name in plist_names:
462
+ logging.debug(f"{time.strftime('%Y-%m-%d %H:%M')} surveying {plist_name}")
463
+
464
+ # Extract node ID from plist name (e.g., "com.autonomi.antnode-1.plist" -> 1)
465
+ node_id_match = re.findall(r"antnode-(\d+)\.plist", plist_name)
466
+ if not node_id_match:
467
+ logging.info(f"Can't decode {plist_name}")
468
+ continue
469
+
470
+ node_id = int(node_id_match[0])
471
+
472
+ card = {
473
+ "node_name": f"{node_id:04}",
474
+ "service": plist_name,
475
+ "timestamp": int(time.time()),
476
+ "host": machine_config.host or "127.0.0.1",
477
+ "method": "launchctl",
478
+ "layout": "1",
479
+ }
480
+
481
+ # Read configuration from plist file
482
+ config = self._read_plist_file(plist_name, machine_config)
483
+ card.update(config)
484
+
485
+ if not config:
486
+ logging.warning(f"Could not read config from {plist_name}")
487
+ continue
488
+
489
+ # Check if node is running by querying metrics port
490
+ metadata = read_node_metadata(card["host"], card["metrics_port"])
491
+
492
+ if isinstance(metadata, dict) and metadata.get("status") == RUNNING:
493
+ # Node is running - collect metadata and metrics
494
+ card.update(metadata)
495
+ card.update(read_node_metrics(card["host"], card["metrics_port"]))
496
+ else:
497
+ # Node is stopped
498
+ if not os.path.isdir(card.get("root_dir", "")):
499
+ card["status"] = DEAD
500
+ card["version"] = ""
501
+ else:
502
+ card["status"] = STOPPED
503
+ card["version"] = get_antnode_version(card.get("binary", ""))
504
+ card["peer_id"] = ""
505
+ card["records"] = 0
506
+ card["uptime"] = 0
507
+ card["shunned"] = 0
508
+
509
+ card["age"] = get_node_age(card.get("root_dir", ""))
510
+ card["host"] = machine_config.host # Ensure we use machine config host
511
+
512
+ details.append(card)
513
+
514
+ return details
515
+
516
+ def _read_plist_file(self, plist_name: str, machine_config) -> dict:
517
+ """
518
+ Read node configuration from a launchd plist file.
519
+
520
+ Args:
521
+ plist_name: Name of the plist file (e.g., "com.autonomi.antnode-1.plist")
522
+ machine_config: Machine configuration object
523
+
524
+ Returns:
525
+ Dictionary with node configuration, or empty dict on error
526
+ """
527
+ details = {}
528
+ plist_path = os.path.join(self.plist_dir, plist_name)
529
+
530
+ try:
531
+ with open(plist_path, "rb") as file:
532
+ plist_data = plistlib.load(file)
533
+
534
+ # Extract node ID from label
535
+ label = plist_data.get("Label", "")
536
+ node_id_match = re.findall(r"antnode-(\d+)", label)
537
+ if not node_id_match:
538
+ logging.warning(f"Could not extract node ID from plist label: {label}")
539
+ return details
540
+
541
+ details["id"] = int(node_id_match[0])
542
+
543
+ # Extract arguments from ProgramArguments
544
+ args = plist_data.get("ProgramArguments", [])
545
+ if len(args) < 1:
546
+ logging.warning(f"No program arguments in plist: {plist_name}")
547
+ return details
548
+
549
+ details["binary"] = args[0]
550
+
551
+ # Parse arguments
552
+ i = 1
553
+ while i < len(args):
554
+ arg = args[i]
555
+ if arg == "--port" and i + 1 < len(args):
556
+ details["port"] = int(args[i + 1])
557
+ i += 2
558
+ elif arg == "--metrics-server-port" and i + 1 < len(args):
559
+ details["metrics_port"] = int(args[i + 1])
560
+ i += 2
561
+ elif arg == "--root-dir" and i + 1 < len(args):
562
+ details["root_dir"] = args[i + 1]
563
+ i += 2
564
+ elif arg == "--rewards-address" and i + 1 < len(args):
565
+ details["wallet"] = args[i + 1]
566
+ i += 1
567
+ # Network is the next argument
568
+ if i + 1 < len(args):
569
+ details["network"] = args[i + 1]
570
+ i += 1
571
+ elif arg == "--ip" and i + 1 < len(args):
572
+ ip = args[i + 1]
573
+ details["host"] = machine_config.host if ip == "0.0.0.0" else ip
574
+ i += 2
575
+ else:
576
+ i += 1
577
+
578
+ # Default host if not specified
579
+ if "host" not in details:
580
+ details["host"] = machine_config.host
581
+
582
+ # Extract environment variables
583
+ env_vars = plist_data.get("EnvironmentVariables", {})
584
+ details["environment"] = " ".join(f"{k}={v}" for k, v in env_vars.items())
585
+
586
+ # macOS runs as current user (no user field in plist)
587
+ details["user"] = os.getenv("USER", "unknown")
588
+
589
+ except Exception as e:
590
+ logging.debug(f"Error reading plist file {plist_path}: {e}")
591
+
592
+ return details