wnm 0.0.9__py3-none-any.whl → 0.0.11__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.
- wnm/__init__.py +1 -1
- wnm/__main__.py +184 -1133
- wnm/actions.py +45 -0
- wnm/common.py +21 -0
- wnm/config.py +653 -1
- wnm/decision_engine.py +388 -0
- wnm/executor.py +1292 -0
- wnm/firewall/__init__.py +13 -0
- wnm/firewall/base.py +71 -0
- wnm/firewall/factory.py +95 -0
- wnm/firewall/null_firewall.py +71 -0
- wnm/firewall/ufw_manager.py +118 -0
- wnm/migration.py +42 -0
- wnm/models.py +305 -126
- wnm/process_managers/__init__.py +23 -0
- wnm/process_managers/base.py +203 -0
- wnm/process_managers/docker_manager.py +371 -0
- wnm/process_managers/factory.py +83 -0
- wnm/process_managers/launchd_manager.py +592 -0
- wnm/process_managers/setsid_manager.py +340 -0
- wnm/process_managers/systemd_manager.py +529 -0
- wnm/reports.py +286 -0
- wnm/utils.py +403 -0
- wnm-0.0.11.dist-info/METADATA +316 -0
- wnm-0.0.11.dist-info/RECORD +28 -0
- {wnm-0.0.9.dist-info → wnm-0.0.11.dist-info}/WHEEL +1 -1
- wnm-0.0.9.dist-info/METADATA +0 -95
- wnm-0.0.9.dist-info/RECORD +0 -9
- {wnm-0.0.9.dist-info → wnm-0.0.11.dist-info}/entry_points.txt +0 -0
- {wnm-0.0.9.dist-info → wnm-0.0.11.dist-info}/top_level.txt +0 -0
|
@@ -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
|