manta-node 0.5b0__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.
Files changed (43) hide show
  1. manta_node/__init__.py +5 -0
  2. manta_node/__main__.py +57 -0
  3. manta_node/cli/__init__.py +6 -0
  4. manta_node/cli/commands/__init__.py +21 -0
  5. manta_node/cli/commands/cluster.py +419 -0
  6. manta_node/cli/commands/config.py +490 -0
  7. manta_node/cli/commands/logs.py +168 -0
  8. manta_node/cli/commands/start.py +459 -0
  9. manta_node/cli/commands/status.py +204 -0
  10. manta_node/cli/commands/stop.py +253 -0
  11. manta_node/cli/config_manager.py +139 -0
  12. manta_node/cli/main.py +133 -0
  13. manta_node/cli/version.py +106 -0
  14. manta_node/domain/__init__.py +8 -0
  15. manta_node/domain/task_lifecycle.py +90 -0
  16. manta_node/infrastructure/__init__.py +13 -0
  17. manta_node/infrastructure/config/__init__.py +31 -0
  18. manta_node/infrastructure/config/node_config_manager.py +918 -0
  19. manta_node/infrastructure/container/__init__.py +11 -0
  20. manta_node/infrastructure/container/docker_adapter.py +253 -0
  21. manta_node/infrastructure/container/image_executor.py +222 -0
  22. manta_node/infrastructure/container/manager.py +549 -0
  23. manta_node/infrastructure/filesystem/__init__.py +7 -0
  24. manta_node/infrastructure/filesystem/dataset_manager.py +469 -0
  25. manta_node/infrastructure/grpc/__init__.py +11 -0
  26. manta_node/infrastructure/grpc/client.py +373 -0
  27. manta_node/infrastructure/grpc/local_servicer.py +151 -0
  28. manta_node/infrastructure/grpc/world_servicer.py +284 -0
  29. manta_node/infrastructure/metrics/__init__.py +10 -0
  30. manta_node/infrastructure/metrics/collector.py +94 -0
  31. manta_node/infrastructure/metrics/metrics_collector.py +351 -0
  32. manta_node/infrastructure/mqtt/__init__.py +7 -0
  33. manta_node/infrastructure/mqtt/command_handler.py +450 -0
  34. manta_node/node_orchestrator.py +462 -0
  35. manta_node/task_manager.py +677 -0
  36. manta_node/tasks.py +273 -0
  37. manta_node/utils.py +52 -0
  38. manta_node-0.5b0.dist-info/METADATA +794 -0
  39. manta_node-0.5b0.dist-info/RECORD +43 -0
  40. manta_node-0.5b0.dist-info/WHEEL +5 -0
  41. manta_node-0.5b0.dist-info/entry_points.txt +2 -0
  42. manta_node-0.5b0.dist-info/licenses/LICENSE +683 -0
  43. manta_node-0.5b0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,549 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from math import nan
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, Tuple, Union, cast
8
+
9
+ import aiodocker
10
+ import docker
11
+ from aiodocker.docker import DockerContainer
12
+
13
+ from manta_common.build.common.tasks import TaskStatus
14
+ from manta_common.conversions import ID
15
+ from manta_common.errors import MantaDockerError
16
+ from manta_common.traces import Tracer
17
+ from ...domain.task_lifecycle import TaskLifecycleService
18
+ from .docker_adapter import DockerRaiser
19
+
20
+
21
+ class ContainerManager(DockerRaiser):
22
+ """
23
+ ContainerManager class to run tasks in docker containers
24
+
25
+ Parameters
26
+ ----------
27
+ node_id: ID
28
+ Node ID
29
+ docker_client: DockerClient
30
+ Docker client
31
+ async_client: AiodockerClient
32
+ Aiodocker client
33
+ containers: Dict[ID, DockerContainer]
34
+ Containers
35
+ gpu_config: dict
36
+ GPU configuration
37
+ """
38
+
39
+ tracer: Tracer
40
+
41
+ def __init__(self):
42
+ self.docker_client = docker.from_env()
43
+
44
+ # Windows-specific: Use TCP connection instead of Named Pipes to avoid event loop conflict
45
+ # SECURITY NOTE: Port 2375 is typically unencrypted. For production deployments on Windows:
46
+ # 1. Enable TLS on Docker daemon (port 2376)
47
+ # 2. Configure client certificates
48
+ # 3. Update connection URL to tcp://localhost:2376 with tls=True
49
+ # See: https://docs.docker.com/engine/security/protect-access/
50
+ if sys.platform.lower() == "win32" or os.name.lower() == "nt":
51
+ try:
52
+ # Docker Desktop on Windows exposes the API on tcp://localhost:2375
53
+ # WARNING: This connection is unencrypted. Use only in development.
54
+ self.async_client = aiodocker.Docker(url="tcp://localhost:2375")
55
+ self.tracer.warning(
56
+ "Using unencrypted Docker TCP connection on Windows (port 2375). "
57
+ "For production, configure TLS on port 2376."
58
+ )
59
+ except Exception as e:
60
+ self.tracer.error(
61
+ f"Failed to connect to Docker on Windows via TCP: {e}. "
62
+ "Ensure Docker Desktop is running and exposing TCP port 2375."
63
+ )
64
+ raise
65
+ else:
66
+ # Unix systems use the default socket
67
+ self.async_client = aiodocker.Docker()
68
+
69
+ self.containers: Dict[Tuple[ID, ID], DockerContainer] = {}
70
+ self.gpu_config = self.get_gpu_config()
71
+ if self.gpu_config is not None:
72
+ self.tracer.debug(
73
+ f"GPU support detected: \n{self.gpu_config}"
74
+ ) # pragma: no cover
75
+ else:
76
+ self.tracer.warning("No GPU support detected.")
77
+
78
+ @property
79
+ def gpu_supported(self):
80
+ return self.gpu_config is not None
81
+
82
+ def get_gpu_config(self) -> Optional[Dict[str, Any]]:
83
+ """
84
+ Check if GPU is supported and return the appropriate config field for aiodocker.
85
+
86
+ Returns
87
+ -------
88
+ Optional[Dict[str, Any]]
89
+ GPU configuration
90
+ """
91
+ # Get Docker version
92
+ version_info = self.docker_client.version()
93
+ docker_version = tuple(
94
+ map(int, version_info["Version"].split(".")[:2])
95
+ ) # Extract major.minor
96
+
97
+ # Check runtimes if Docker < 19.03
98
+ if docker_version < (19, 3):
99
+ if self._is_nvidia_runtime_installed():
100
+ return {"Runtime": "nvidia"} # Old way
101
+ return None # No GPU support
102
+
103
+ # Check for NVIDIA Container Toolkit (Docker >= 19.03)
104
+ if self._is_nvidia_toolkit_installed():
105
+ return {
106
+ "HostConfig": {
107
+ "DeviceRequests": [
108
+ {"Driver": "nvidia", "Count": -1, "Capabilities": [["gpu"]]}
109
+ ]
110
+ }
111
+ }
112
+ elif self._is_nvidia_runtime_installed():
113
+ return {"Runtime": "nvidia"}
114
+
115
+ return None # No GPU support detected
116
+
117
+ def _is_nvidia_runtime_installed(self):
118
+ """Method 1: Check if nvidia runtime is installed."""
119
+ info = self.docker_client.info()
120
+ return "nvidia" in info.get("Runtimes", {})
121
+
122
+ def _is_nvidia_toolkit_installed(self):
123
+ """Method 2: Check if nvidia-container-toolkit is installed."""
124
+ try:
125
+ subprocess.run(
126
+ ["nvidia-container-cli", "-V"],
127
+ stdout=subprocess.PIPE,
128
+ stderr=subprocess.PIPE,
129
+ check=True,
130
+ )
131
+ return True
132
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
133
+ self.tracer.debug(f"nvidia-container-cli not found: {e}")
134
+
135
+ return False
136
+
137
+ async def ensure_tailscale_service(
138
+ self,
139
+ swarm_id: ID,
140
+ authkey: str,
141
+ login_server: str,
142
+ hostname: str,
143
+ temp_folder: Path,
144
+ ):
145
+ """
146
+ Ensure the tailscale service is running
147
+
148
+ Parameters
149
+ ----------
150
+ swarm_id : ID
151
+ Swarm ID
152
+ authkey : str
153
+ Tailscale auth key
154
+ login_server : str
155
+ Tailscale login server
156
+ hostname : str
157
+ Tailscale hostname
158
+ temp_folder : Path
159
+ Temporary folder
160
+
161
+ Returns
162
+ -------
163
+ datetime
164
+ Start time of the container
165
+ """
166
+ # network = f"tailscale-{swarm_id}"
167
+ # Crée le réseau Docker s’il n’existe pas
168
+ # if not self.docker_client.networks.list(names=[network]):
169
+ # self.docker_client.networks.create(network, driver="bridge", attachable=True)
170
+ # Lancement du conteneur Tailscale
171
+ config = {
172
+ "Image": "tailscale/tailscale:stable",
173
+ "HostConfig": {
174
+ "Hostname": hostname,
175
+ "Binds": [
176
+ f"tailscale-{swarm_id}-data:/var/lib/tailscale",
177
+ "/dev/net/tun:/dev/net/tun",
178
+ ],
179
+ "CapAdd": ["NET_ADMIN", "SYS_MODULE"],
180
+ "RestartPolicy": {"Name": "unless-stopped"},
181
+ "NetworkMode": "host",
182
+ },
183
+ "Env": [
184
+ f"TS_AUTHKEY=tskey-client-{authkey}?ephemeral=false",
185
+ "TS_SERVE_CONFIG=/config/config.json",
186
+ "TS_STATE_DIR=/var/lib/tailscale",
187
+ "TS_USERSPACE=false",
188
+ f"TS_EXTRA_ARGS=--login-server={login_server} --advertise-tags=tag:container --reset",
189
+ ],
190
+ "Volumes": {f"tailscale-{swarm_id}-data": {}},
191
+ "Detach": True,
192
+ "Labels": {
193
+ "com.docker.compose.project": "manta_tasks",
194
+ "com.docker.compose.service": "stirling-ts", # optionnel
195
+ },
196
+ }
197
+ self.tracer.debug("Running tailscale with following configuration:")
198
+ for key, value in config.items():
199
+ self.tracer.debug(f" {key}: {value}")
200
+ self.containers[
201
+ (swarm_id, ID(f"tailscale-{swarm_id}"))
202
+ ] = await self.run_or_raise(config)
203
+ return f"service:tailscale-{swarm_id}"
204
+
205
+ async def run_container(
206
+ self,
207
+ swarm_id: ID,
208
+ task_id: ID,
209
+ image: str,
210
+ command: str,
211
+ volumes: dict,
212
+ environment: Optional[dict] = None,
213
+ memory: Optional[Union[int, str]] = None,
214
+ cpus: Optional[str] = None,
215
+ gpu: Optional[bool] = False,
216
+ network: Optional[str] = None,
217
+ alias: Optional[str] = None,
218
+ force_pull: bool = False,
219
+ ) -> datetime:
220
+ """
221
+ Run a container with the given image, command, volumes and environment
222
+
223
+ Parameters
224
+ ----------
225
+ swarm_id : ID
226
+ ID of the swarm
227
+ task_id : ID
228
+ ID of the task
229
+ image : str
230
+ Image to run the container
231
+ command : str
232
+ Command to run in the container
233
+ volumes : list
234
+ Volumes to mount in the container
235
+ environment : Optional[dict]
236
+ Environment variables
237
+ memory : Optional[Union[int, str]]
238
+ Memory limit.
239
+ Accepts float values (which represent the memory limit of
240
+ the created container in bytes) or a string with a units
241
+ identification char (100000b, 1000k, 128m, 1g).
242
+ If a string is specified without a units character, bytes
243
+ are assumed as an intended unit.
244
+ cpus : Optional[str]
245
+ CPU limit. CPUs in which to allow execution (0-3, 0, 1)
246
+ or a percentage of the available CPUs
247
+ gpu : Optional[bool]
248
+ GPU support
249
+ network : Optional[str]
250
+ Network to attach to the container
251
+ alias : Optional[str]
252
+ Alias of the container
253
+ force_pull : bool
254
+ Force pull the image before running the container
255
+
256
+ Returns
257
+ -------
258
+ datetime
259
+ Start time of the container
260
+ """
261
+ if gpu and not self.gpu_supported:
262
+ raise MantaDockerError(
263
+ "GPU support is not enabled for this container manager.",
264
+ metadata=self.tracer.metadata,
265
+ )
266
+
267
+ host_config = (
268
+ self.gpu_config.get("HostConfig", {}) if (gpu and self.gpu_config) else {}
269
+ )
270
+ host_config["Binds"] = [
271
+ f"{str(host_path) if isinstance(host_path, Path) else host_path}:{bind['bind']}:{bind['mode']}"
272
+ for host_path, bind in volumes.items()
273
+ ]
274
+ host_config["NetworkMode"] = network if network else "host"
275
+ host_config["Hostname"] = alias if alias else None
276
+ host_config["ExtraHosts"] = ["host.docker.internal:host-gateway"]
277
+ env = (
278
+ [f"{key}={value}" for key, value in environment.items()]
279
+ if environment
280
+ else None
281
+ )
282
+ log_config = {
283
+ "type": "json-file",
284
+ "config": {"max-size": "10m", "max-file": "3"},
285
+ }
286
+
287
+ config = {
288
+ "Cmd": command.split(),
289
+ "Image": image,
290
+ "HostConfig": host_config,
291
+ "Env": env,
292
+ "Detach": True,
293
+ "Memory": memory,
294
+ "CpuShares": cpus,
295
+ "LogConfig": log_config,
296
+ "Runtime": (
297
+ self.gpu_config.get("Runtime") if (gpu and self.gpu_config) else None
298
+ ),
299
+ "Labels": {
300
+ "com.docker.compose.project": "manta_tasks",
301
+ "manta.swarm-id": ID(swarm_id).xid,
302
+ "manta.task-id": ID(task_id).xid,
303
+ },
304
+ }
305
+
306
+ start_time = datetime.now(timezone.utc)
307
+ self.tracer.debug("Running container with following configuration:")
308
+ for key, value in config.items():
309
+ self.tracer.debug(f" {key}: {value}")
310
+
311
+ if force_pull:
312
+ self.tracer.info(f"Pulling image {image}...")
313
+ await self.pull_image_or_raise(image)
314
+
315
+ self.containers[(swarm_id, task_id)] = await self.run_or_raise(config)
316
+
317
+ self.tracer.info(f"Task {task_id} container started.")
318
+ return start_time
319
+
320
+ async def restart_container(self, swarm_id: ID, task_id: ID) -> datetime:
321
+ """
322
+ Restart a task's container
323
+
324
+ Parameters
325
+ ----------
326
+ swarm_id : ID
327
+ Swarm ID
328
+ task_id : ID
329
+ Task ID
330
+
331
+ Returns
332
+ -------
333
+ datetime
334
+ Start time of the container
335
+
336
+ Raises
337
+ ------
338
+ MantaDockerError
339
+ Task not found
340
+ """
341
+ if container := self.containers.get((swarm_id, task_id)):
342
+ start_time = datetime.now(timezone.utc)
343
+ await self.restart_or_raise(container)
344
+ self.tracer.debug(f"Task {task_id} container restarted.")
345
+ return start_time
346
+ message = f"Task {task_id} not found."
347
+ self.tracer.error(message)
348
+ raise MantaDockerError(message, metadata=self.tracer.metadata)
349
+
350
+ async def stop_container(self, swarm_id: ID, task_id: ID):
351
+ """
352
+ Stop a running container
353
+
354
+ Parameters
355
+ ----------
356
+ swarm_id : ID
357
+ Swarm ID
358
+ task_id : ID
359
+ Task ID
360
+
361
+ Raises
362
+ ------
363
+ MantaDockerError
364
+ Task not found
365
+ """
366
+ if container := self.containers.get((swarm_id, task_id)):
367
+ await self.stop_or_raise(container)
368
+ self.tracer.debug(f"Task {task_id} container stopped.")
369
+ else:
370
+ message = f"Task {task_id} not found."
371
+ self.tracer.error(message)
372
+ raise MantaDockerError(message, metadata=self.tracer.metadata)
373
+
374
+ async def remove_container(self, swarm_id: ID, task_id: ID):
375
+ """
376
+ Remove a container
377
+
378
+ Parameters
379
+ ----------
380
+ swarm_id : ID
381
+ Swarm ID
382
+ task_id : ID
383
+ Task ID
384
+
385
+ Raises
386
+ ------
387
+ MantaDockerError
388
+ Task not found
389
+ """
390
+ if container := self.containers.pop((swarm_id, task_id), None):
391
+ await self.remove_or_raise(container)
392
+ self.tracer.debug(f"Task {task_id} container removed.")
393
+ else:
394
+ message = f"Task {task_id} not found."
395
+ self.tracer.error(message)
396
+ raise MantaDockerError(message, metadata=self.tracer.metadata)
397
+
398
+ async def get_task_status(self, swarm_id: ID, task_id: ID) -> TaskStatus:
399
+ """
400
+ Get the status of a container
401
+
402
+ Parameters
403
+ ----------
404
+ swarm_id : ID
405
+ Swarm ID
406
+ task_id : ID
407
+ Task ID
408
+
409
+ Returns
410
+ -------
411
+ TaskStatus
412
+ Status of the container
413
+
414
+ Raises
415
+ ------
416
+ MantaDockerError
417
+ Container not found
418
+ Cannot reload container
419
+ """
420
+ container = self.containers.get((swarm_id, task_id))
421
+ if container is None:
422
+ self.tracer.error("Cannot found container")
423
+ self.tracer.info(
424
+ f"ContainerManager.get_task_status: container NOT FOUND "
425
+ f"task={task_id} swarm={swarm_id} "
426
+ f"containers_keys={list(self.containers.keys())}"
427
+ )
428
+ raise MantaDockerError(
429
+ "Cannot found container", metadata=self.tracer.metadata
430
+ )
431
+
432
+ self.tracer.info(
433
+ f"ContainerManager.get_task_status: container FOUND "
434
+ f"task={task_id} swarm={swarm_id} container_id={container.id}"
435
+ )
436
+ # self.reload_or_raise(container)
437
+ status = await self.get_status_or_raise(container)
438
+ self.tracer.info(
439
+ f"ContainerManager.get_task_status: Docker status={status} task={task_id}"
440
+ )
441
+
442
+ # Use domain service to map container status to task status
443
+ return TaskLifecycleService.calculate_status_from_container(status)
444
+
445
+ async def get_container_logs(
446
+ self, swarm_id: ID, task_id: ID, since: Optional[datetime] = None
447
+ ) -> bytes:
448
+ """
449
+ Get the logs of a container (async)
450
+
451
+ Parameters
452
+ ----------
453
+ swarm_id : ID
454
+ Swarm ID
455
+ task_id : ID
456
+ Task ID
457
+ since : Optional[datetime]
458
+ Show logs since a given datetime
459
+
460
+ Returns
461
+ -------
462
+ bytes
463
+ Logs from the container
464
+
465
+ Raises
466
+ ------
467
+ MantaDockerError
468
+ Cannot found container
469
+ Cannot get container's logs
470
+ """
471
+ container = self.containers.get((swarm_id, task_id))
472
+ if container is None:
473
+ message = f"Task {task_id} not found for swarm {swarm_id}."
474
+ self.tracer.error(message)
475
+ raise MantaDockerError(message, metadata=self.tracer.metadata)
476
+ return await self.get_container_logs_or_raise(container, since)
477
+
478
+ async def get_task_stats(self, swarm_id: ID, task_id: ID) -> dict:
479
+ """
480
+ Get the stats of a task
481
+
482
+ Parameters
483
+ ----------
484
+ swarm_id : ID
485
+ Swarm ID
486
+ task_id : ID
487
+ Task ID
488
+
489
+ Returns
490
+ -------
491
+ dict
492
+ Stats from the container
493
+
494
+ Raises
495
+ ------
496
+ MantaDockerError
497
+ Task not found
498
+ """
499
+ if container := self.containers.get((swarm_id, task_id)):
500
+ stats = cast(Dict[str, Any], await container.stats(stream=False))
501
+
502
+ cpu_usage = stats["cpu_stats"]["cpu_usage"]["total_usage"]
503
+ system_cpu_usage = stats["cpu_stats"]["system_cpu_usage"]
504
+ online_cpus = stats["cpu_stats"]["online_cpus"]
505
+ cpu_percent = (cpu_usage / system_cpu_usage) * online_cpus * 100
506
+
507
+ cpu = dict(
508
+ total=float(100.0),
509
+ available=100.0 - cpu_percent,
510
+ used=cpu_percent,
511
+ )
512
+
513
+ memory_usage = stats["memory_stats"]["usage"]
514
+ memory_limit = stats["memory_stats"]["limit"]
515
+
516
+ memory = dict(
517
+ total=float(memory_limit),
518
+ available=float(memory_usage / memory_limit * 100),
519
+ used=float(memory_usage),
520
+ )
521
+ else:
522
+ cpu = dict(
523
+ total=100.0,
524
+ available=nan,
525
+ used=nan,
526
+ )
527
+ memory = dict(
528
+ total=nan,
529
+ available=nan,
530
+ used=nan,
531
+ )
532
+
533
+ return {"cpu": cpu, "memory": memory}
534
+
535
+ # TODO : use (swarm_id, task_id) instead of only task_id
536
+ async def collect_system_summary(self) -> dict:
537
+ """
538
+ Collect the current system metrics
539
+
540
+ Returns
541
+ -------
542
+ dict
543
+ System informations
544
+ """
545
+ # Iterate over all containers (tasks) and get their stats
546
+ return {
547
+ task_id.xid: await self.get_task_stats(swarm_id, task_id)
548
+ for (swarm_id, task_id) in self.containers
549
+ }
@@ -0,0 +1,7 @@
1
+ """Filesystem infrastructure - Dataset and file management."""
2
+
3
+ from .dataset_manager import DatasetManager
4
+
5
+ __all__ = [
6
+ "DatasetManager",
7
+ ]