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.

@@ -0,0 +1,203 @@
1
+ """
2
+ Abstract base class for process managers.
3
+
4
+ Process managers handle node lifecycle operations across different
5
+ execution environments (systemd, docker, setsid, etc.)
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from wnm.firewall.factory import get_firewall_manager
13
+ from wnm.models import Node
14
+
15
+
16
+ @dataclass
17
+ class NodeProcess:
18
+ """Represents the runtime state of a node process"""
19
+
20
+ node_id: int
21
+ pid: Optional[int] = None
22
+ status: str = "UNKNOWN" # RUNNING, STOPPED, UPGRADING, etc.
23
+ container_id: Optional[str] = None # For docker-managed nodes
24
+
25
+
26
+ class ProcessManager(ABC):
27
+ """
28
+ Abstract interface for node lifecycle management.
29
+
30
+ Each implementation handles a specific process management backend:
31
+ - SystemdManager: systemd services (Linux)
32
+ - DockerManager: Docker containers
33
+ - SetsidManager: Background processes via setsid
34
+ - AntctlManager: Wrapper around antctl CLI
35
+ - LaunchctlManager: macOS launchd services
36
+ """
37
+
38
+ def __init__(self, firewall_type: str = None):
39
+ """
40
+ Initialize process manager with optional firewall manager.
41
+
42
+ Args:
43
+ firewall_type: Type of firewall to use ("ufw", "null", etc.)
44
+ If None, auto-detects best available option
45
+ """
46
+ self.firewall = get_firewall_manager(firewall_type)
47
+
48
+ @abstractmethod
49
+ def create_node(self, node: Node, binary_path: str) -> bool:
50
+ """
51
+ Create and start a new node.
52
+
53
+ Args:
54
+ node: Node database record with configuration
55
+ binary_path: Path to the node binary to execute
56
+
57
+ Returns:
58
+ True if node was created successfully
59
+ """
60
+ pass
61
+
62
+ @abstractmethod
63
+ def start_node(self, node: Node) -> bool:
64
+ """
65
+ Start a stopped node.
66
+
67
+ Args:
68
+ node: Node database record
69
+
70
+ Returns:
71
+ True if node started successfully
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def stop_node(self, node: Node) -> bool:
77
+ """
78
+ Stop a running node.
79
+
80
+ Args:
81
+ node: Node database record
82
+
83
+ Returns:
84
+ True if node stopped successfully
85
+ """
86
+ pass
87
+
88
+ @abstractmethod
89
+ def restart_node(self, node: Node) -> bool:
90
+ """
91
+ Restart a node.
92
+
93
+ Args:
94
+ node: Node database record
95
+
96
+ Returns:
97
+ True if node restarted successfully
98
+ """
99
+ pass
100
+
101
+ @abstractmethod
102
+ def get_status(self, node: Node) -> NodeProcess:
103
+ """
104
+ Get current runtime status of a node.
105
+
106
+ Args:
107
+ node: Node database record
108
+
109
+ Returns:
110
+ NodeProcess with current status and PID
111
+ """
112
+ pass
113
+
114
+ @abstractmethod
115
+ def remove_node(self, node: Node) -> bool:
116
+ """
117
+ Stop and remove all traces of a node.
118
+
119
+ This should:
120
+ 1. Stop the node process
121
+ 2. Remove service/container definitions
122
+ 3. Optionally clean up data directories (controlled by node.keep_data)
123
+
124
+ Args:
125
+ node: Node database record
126
+
127
+ Returns:
128
+ True if node was removed successfully
129
+ """
130
+ pass
131
+
132
+ @abstractmethod
133
+ def survey_nodes(self, machine_config) -> list:
134
+ """
135
+ Survey all nodes managed by this process manager.
136
+
137
+ This is used during database initialization/rebuild to discover
138
+ existing nodes by scanning the manager's configuration directory.
139
+ Each manager handles its own path logic internally.
140
+
141
+ The database is the source of truth for regular operations.
142
+ This method is ONLY used for initialization and migration.
143
+
144
+ Args:
145
+ machine_config: Machine configuration object
146
+
147
+ Returns:
148
+ List of node dictionaries ready for database insertion
149
+ """
150
+ pass
151
+
152
+ def enable_firewall_port(
153
+ self, port: int, protocol: str = "udp", comment: str = None
154
+ ) -> bool:
155
+ """
156
+ Open firewall port for node communication.
157
+
158
+ Uses the configured firewall manager to open the port.
159
+ Subclasses can override for custom firewall behavior.
160
+
161
+ Args:
162
+ port: Port number to open
163
+ protocol: Protocol type (udp/tcp)
164
+ comment: Optional comment for the firewall rule
165
+
166
+ Returns:
167
+ True if port was opened successfully
168
+ """
169
+ return self.firewall.enable_port(port, protocol, comment)
170
+
171
+ def disable_firewall_port(self, port: int, protocol: str = "udp") -> bool:
172
+ """
173
+ Close firewall port when node is removed.
174
+
175
+ Uses the configured firewall manager to close the port.
176
+ Subclasses can override for custom firewall behavior.
177
+
178
+ Args:
179
+ port: Port number to close
180
+ protocol: Protocol type (udp/tcp)
181
+
182
+ Returns:
183
+ True if port was closed successfully
184
+ """
185
+ return self.firewall.disable_port(port, protocol)
186
+
187
+ def teardown_cluster(self) -> bool:
188
+ """
189
+ Teardown the entire cluster using manager-specific commands.
190
+
191
+ This is an optional method that managers can override to provide
192
+ efficient bulk teardown operations. If not overridden, returns False
193
+ to indicate that individual node removal should be used instead.
194
+
195
+ Examples:
196
+ - AntctlManager: Uses 'antctl reset' command
197
+ - Other managers: Return False to use default individual removal
198
+
199
+ Returns:
200
+ True if cluster was torn down successfully using manager-specific method,
201
+ False to indicate fallback to individual node removal
202
+ """
203
+ return False
@@ -0,0 +1,371 @@
1
+ """
2
+ DockerManager: Manage nodes in Docker containers.
3
+
4
+ This manager runs nodes inside Docker containers, allowing for better isolation
5
+ and resource management. Supports both single-node and multi-node containers.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from wnm.common import DEAD, RESTARTING, RUNNING, STOPPED, UPGRADING
16
+ from wnm.config import BOOTSTRAP_CACHE_DIR
17
+ from wnm.models import Node
18
+ from wnm.process_managers.base import NodeProcess, ProcessManager
19
+
20
+
21
+ class DockerManager(ProcessManager):
22
+ """Manage nodes in Docker containers"""
23
+
24
+ def __init__(
25
+ self,
26
+ session_factory=None,
27
+ image="autonomi/node:latest",
28
+ firewall_type: str = "null",
29
+ ):
30
+ """
31
+ Initialize DockerManager.
32
+
33
+ Args:
34
+ session_factory: SQLAlchemy session factory (optional, for status updates)
35
+ image: Docker image to use for nodes
36
+ firewall_type: Type of firewall (defaults to "null" for Docker)
37
+ """
38
+ super().__init__(firewall_type)
39
+ self.S = session_factory
40
+ self.image = image
41
+
42
+ def _get_container_name(self, node: Node) -> str:
43
+ """Get Docker container name for a node"""
44
+ return f"antnode{node.node_name}"
45
+
46
+ def _ensure_image(self) -> bool:
47
+ """Ensure Docker image is available"""
48
+ try:
49
+ # Check if image exists locally
50
+ result = subprocess.run(
51
+ ["docker", "image", "inspect", self.image],
52
+ stdout=subprocess.PIPE,
53
+ stderr=subprocess.PIPE,
54
+ )
55
+ if result.returncode == 0:
56
+ return True
57
+
58
+ # Pull image if not found
59
+ logging.info(f"Pulling Docker image: {self.image}")
60
+ subprocess.run(
61
+ ["docker", "pull", self.image],
62
+ check=True,
63
+ )
64
+ return True
65
+
66
+ except subprocess.CalledProcessError as err:
67
+ logging.error(f"Failed to ensure Docker image: {err}")
68
+ return False
69
+
70
+ def create_node(self, node: Node, binary_path: str) -> bool:
71
+ """
72
+ Create and start a new node in a Docker container.
73
+
74
+ Args:
75
+ node: Node database record with configuration
76
+ binary_path: Path to the antnode binary (will be mounted into container)
77
+
78
+ Returns:
79
+ True if node was created successfully
80
+ """
81
+ logging.info(f"Creating docker node {node.id}")
82
+
83
+ # Ensure image is available
84
+ if not self._ensure_image():
85
+ return False
86
+
87
+ # Prepare directories
88
+ node_dir = Path(node.root_dir)
89
+ try:
90
+ node_dir.mkdir(parents=True, exist_ok=True)
91
+ except OSError as err:
92
+ logging.error(f"Failed to create node directory: {err}")
93
+ return False
94
+
95
+ container_name = self._get_container_name(node)
96
+
97
+ # Build docker run command
98
+ cmd = [
99
+ "docker",
100
+ "run",
101
+ "-d", # Detached mode
102
+ "--name",
103
+ container_name,
104
+ "--restart",
105
+ "unless-stopped",
106
+ # Port mappings
107
+ "-p",
108
+ f"{node.port}:{node.port}/udp",
109
+ "-p",
110
+ f"{node.metrics_port}:{node.metrics_port}/tcp",
111
+ # Volume mounts
112
+ "-v",
113
+ f"{node.root_dir}:/data",
114
+ "-v",
115
+ f"{binary_path}:/usr/local/bin/antnode:ro",
116
+ "-v",
117
+ f"{BOOTSTRAP_CACHE_DIR}:/bootstrap-cache:ro",
118
+ ]
119
+
120
+ # Add environment variables
121
+ if node.environment:
122
+ for env_var in node.environment.split():
123
+ cmd.extend(["-e", env_var])
124
+
125
+ # Add the image
126
+ cmd.append(self.image)
127
+
128
+ # Add the command to run
129
+ cmd.extend(
130
+ [
131
+ "antnode",
132
+ "--root-dir",
133
+ "/data",
134
+ "--port",
135
+ str(node.port),
136
+ "--enable-metrics-server",
137
+ "--metrics-server-port",
138
+ str(node.metrics_port),
139
+ "--bootstrap-cache-dir",
140
+ "/bootstrap-cache",
141
+ "--rewards-address",
142
+ node.wallet,
143
+ node.network,
144
+ ]
145
+ )
146
+
147
+ # Run the container
148
+ try:
149
+ result = subprocess.run(
150
+ cmd,
151
+ stdout=subprocess.PIPE,
152
+ stderr=subprocess.PIPE,
153
+ text=True,
154
+ check=True,
155
+ )
156
+ container_id = result.stdout.strip()
157
+ logging.info(f"Created container {container_name} with ID {container_id}")
158
+
159
+ except subprocess.CalledProcessError as err:
160
+ logging.error(f"Failed to create container: {err}")
161
+ logging.error(f"stderr: {err.stderr}")
162
+ return False
163
+
164
+ # Update database with container info if we have Container model support
165
+ # For now, we'll store container_id in node metadata if needed
166
+
167
+ # Wait a moment for container to start
168
+ time.sleep(1)
169
+
170
+ return True
171
+
172
+ def start_node(self, node: Node) -> bool:
173
+ """
174
+ Start a stopped Docker container.
175
+
176
+ Args:
177
+ node: Node database record
178
+
179
+ Returns:
180
+ True if node started successfully
181
+ """
182
+ logging.info(f"Starting docker node {node.id}")
183
+
184
+ container_name = self._get_container_name(node)
185
+
186
+ try:
187
+ subprocess.run(
188
+ ["docker", "start", container_name],
189
+ stdout=subprocess.PIPE,
190
+ stderr=subprocess.PIPE,
191
+ check=True,
192
+ )
193
+ except subprocess.CalledProcessError as err:
194
+ logging.error(f"Failed to start container: {err}")
195
+ return False
196
+
197
+ return True
198
+
199
+ def stop_node(self, node: Node) -> bool:
200
+ """
201
+ Stop a Docker container.
202
+
203
+ Args:
204
+ node: Node database record
205
+
206
+ Returns:
207
+ True if node stopped successfully
208
+ """
209
+ logging.info(f"Stopping docker node {node.id}")
210
+
211
+ container_name = self._get_container_name(node)
212
+
213
+ try:
214
+ # Stop with 30 second timeout for graceful shutdown
215
+ subprocess.run(
216
+ ["docker", "stop", "-t", "30", container_name],
217
+ stdout=subprocess.PIPE,
218
+ stderr=subprocess.PIPE,
219
+ check=True,
220
+ )
221
+ except subprocess.CalledProcessError as err:
222
+ logging.error(f"Failed to stop container: {err}")
223
+ return False
224
+
225
+ return True
226
+
227
+ def restart_node(self, node: Node) -> bool:
228
+ """
229
+ Restart a Docker container.
230
+
231
+ Args:
232
+ node: Node database record
233
+
234
+ Returns:
235
+ True if node restarted successfully
236
+ """
237
+ logging.info(f"Restarting docker node {node.id}")
238
+
239
+ container_name = self._get_container_name(node)
240
+
241
+ try:
242
+ subprocess.run(
243
+ ["docker", "restart", "-t", "30", container_name],
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.PIPE,
246
+ check=True,
247
+ )
248
+ except subprocess.CalledProcessError as err:
249
+ logging.error(f"Failed to restart container: {err}")
250
+ return False
251
+
252
+ return True
253
+
254
+ def get_status(self, node: Node) -> NodeProcess:
255
+ """
256
+ Get current status of a Docker node.
257
+
258
+ Args:
259
+ node: Node database record
260
+
261
+ Returns:
262
+ NodeProcess with current status
263
+ """
264
+ container_name = self._get_container_name(node)
265
+
266
+ try:
267
+ # Get container info
268
+ result = subprocess.run(
269
+ [
270
+ "docker",
271
+ "inspect",
272
+ "--format",
273
+ "{{.State.Status}}|{{.State.Pid}}|{{.Id}}",
274
+ container_name,
275
+ ],
276
+ stdout=subprocess.PIPE,
277
+ stderr=subprocess.PIPE,
278
+ text=True,
279
+ check=True,
280
+ )
281
+
282
+ status_str, pid_str, container_id = result.stdout.strip().split("|")
283
+
284
+ # Map Docker status to our status
285
+ if status_str == "running":
286
+ status = RUNNING
287
+ elif status_str in ("created", "restarting"):
288
+ status = RESTARTING
289
+ elif status_str in ("exited", "dead"):
290
+ status = STOPPED
291
+ else:
292
+ status = "UNKNOWN"
293
+
294
+ pid = int(pid_str) if pid_str and pid_str != "0" else None
295
+
296
+ # Check if root directory exists
297
+ if not os.path.isdir(node.root_dir):
298
+ status = DEAD
299
+
300
+ return NodeProcess(
301
+ node_id=node.id,
302
+ pid=pid,
303
+ status=status,
304
+ container_id=container_id,
305
+ )
306
+
307
+ except subprocess.CalledProcessError:
308
+ # Container doesn't exist
309
+ return NodeProcess(node_id=node.id, pid=None, status=STOPPED)
310
+ except (ValueError, IndexError) as err:
311
+ logging.error(f"Failed to parse container status: {err}")
312
+ return NodeProcess(node_id=node.id, pid=None, status="UNKNOWN")
313
+
314
+ def remove_node(self, node: Node) -> bool:
315
+ """
316
+ Stop and remove a Docker container.
317
+
318
+ Args:
319
+ node: Node database record
320
+
321
+ Returns:
322
+ True if node was removed successfully
323
+ """
324
+ logging.info(f"Removing docker node {node.id}")
325
+
326
+ container_name = self._get_container_name(node)
327
+
328
+ # Stop the container first
329
+ self.stop_node(node)
330
+
331
+ # Remove the container
332
+ try:
333
+ subprocess.run(
334
+ ["docker", "rm", "-f", container_name],
335
+ stdout=subprocess.PIPE,
336
+ stderr=subprocess.PIPE,
337
+ check=True,
338
+ )
339
+ except subprocess.CalledProcessError as err:
340
+ logging.error(f"Failed to remove container: {err}")
341
+
342
+ # Remove node data directory
343
+ try:
344
+ import shutil
345
+
346
+ node_dir = Path(node.root_dir)
347
+ if node_dir.exists():
348
+ shutil.rmtree(node_dir)
349
+ except (OSError, Exception) as err:
350
+ logging.error(f"Failed to remove node directory: {err}")
351
+
352
+ return True
353
+
354
+ def survey_nodes(self, machine_config) -> list:
355
+ """
356
+ Survey all docker-managed antnode containers.
357
+
358
+ Docker nodes are typically not used for migration scenarios.
359
+ This returns an empty list as docker containers are created
360
+ fresh by WNM and don't pre-exist.
361
+
362
+ Args:
363
+ machine_config: Machine configuration object
364
+
365
+ Returns:
366
+ Empty list (docker nodes don't pre-exist for migration)
367
+ """
368
+ logging.info(
369
+ "Docker survey not implemented (docker nodes created fresh by WNM)"
370
+ )
371
+ return []
@@ -0,0 +1,83 @@
1
+ """
2
+ Factory for creating process manager instances.
3
+
4
+ Provides a centralized way to instantiate the appropriate process manager
5
+ based on the manager type.
6
+ """
7
+
8
+ import logging
9
+
10
+ from wnm.process_managers.base import ProcessManager
11
+ from wnm.process_managers.docker_manager import DockerManager
12
+ from wnm.process_managers.launchd_manager import LaunchctlManager
13
+ from wnm.process_managers.setsid_manager import SetsidManager
14
+ from wnm.process_managers.systemd_manager import SystemdManager
15
+
16
+
17
+ def get_process_manager(
18
+ manager_type: str, session_factory=None, **kwargs
19
+ ) -> ProcessManager:
20
+ """
21
+ Get a process manager instance for the specified type.
22
+
23
+ Args:
24
+ manager_type: Type of process manager ("systemd", "docker", "setsid", etc.)
25
+ session_factory: SQLAlchemy session factory for database operations
26
+ **kwargs: Additional arguments to pass to the manager constructor
27
+
28
+ Returns:
29
+ ProcessManager instance
30
+
31
+ Raises:
32
+ ValueError: If manager_type is not supported
33
+ """
34
+ managers = {
35
+ "systemd": SystemdManager,
36
+ "docker": DockerManager,
37
+ "setsid": SetsidManager,
38
+ "launchctl": LaunchctlManager,
39
+ # Future managers:
40
+ # "antctl": AntctlManager,
41
+ }
42
+
43
+ manager_class = managers.get(manager_type)
44
+ if not manager_class:
45
+ supported = ", ".join(managers.keys())
46
+ raise ValueError(
47
+ f"Unsupported manager type: {manager_type}. "
48
+ f"Supported types: {supported}"
49
+ )
50
+
51
+ logging.debug(f"Creating {manager_type} process manager")
52
+ return manager_class(session_factory=session_factory, **kwargs)
53
+
54
+
55
+ def get_default_manager_type() -> str:
56
+ """
57
+ Determine the default process manager type for the current system.
58
+
59
+ Returns:
60
+ Default manager type string ("systemd", "setsid", etc.)
61
+ """
62
+ import platform
63
+ import shutil
64
+
65
+ system = platform.system()
66
+
67
+ # Linux: prefer systemd if available
68
+ if system == "Linux":
69
+ if shutil.which("systemctl"):
70
+ return "systemd"
71
+ return "setsid"
72
+
73
+ # macOS: use launchctl for native macOS support
74
+ if system == "Darwin":
75
+ return "launchctl"
76
+
77
+ # Windows: not supported
78
+ if system == "Windows":
79
+ raise RuntimeError("Windows is not currently supported")
80
+
81
+ # Unknown system: fall back to setsid
82
+ logging.warning(f"Unknown system {system}, defaulting to setsid manager")
83
+ return "setsid"