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,450 @@
1
+ import asyncio
2
+ import io
3
+ from datetime import datetime, timezone
4
+ from logging import getLogger
5
+ from pathlib import Path
6
+ from typing import AsyncIterable, Awaitable, Callable, List, Optional, cast
7
+
8
+ import aiomqtt
9
+
10
+ from manta_common.base_mqtt import MqttBase
11
+ from manta_common.build.common.mqtt import MqMetricsSnapshot, MqTaskUpdate
12
+ from manta_common.build.common.system import Logs
13
+ from manta_common.build.common.tasks import MqttTask, TaskStatus, TaskUpdate
14
+ from manta_common.const import CHUNK_SIZE
15
+ from manta_common.conversions import ID
16
+ from manta_common.errors import (
17
+ MantaError,
18
+ MantaGRPCError,
19
+ MantaMQTTError,
20
+ MantaNodeError,
21
+ )
22
+ from manta_common.traces import Tracer
23
+ from ...task_manager import TaskManager, LOG_FLUSH_INTERVAL
24
+ from ..filesystem.dataset_manager import DatasetManager
25
+ from ..grpc.client import NodeClient
26
+ from ..metrics.collector import Collector
27
+ from ..metrics.metrics_collector import MetricsCollectorBase
28
+
29
+ TIMEOUT = 1 # s
30
+
31
+
32
+ class CommandHandler(TaskManager, Collector, MqttBase):
33
+ """
34
+ CommandHandler class to handle commands from the Manager
35
+
36
+ Parameters
37
+ ----------
38
+ node_id: ID
39
+ Node ID
40
+ mqtt_client: MQTTClient
41
+ MQTT client
42
+ node_client: NodeClient
43
+ Node client
44
+ mqtt_broker_host : str
45
+ MQTT broker host
46
+ mqtt_broker_port : int
47
+ MQTT broker port
48
+ topics : List[str]
49
+ List of topics
50
+ light_service_port: int
51
+ Light service port
52
+ dataset_manager: DatasetManager
53
+ Dataset manager
54
+ metrics_collector: MetricsCollectorBase
55
+ Metrics collector
56
+ stop_callback: Callable[[], Awaitable[None]]
57
+ Stop callback
58
+ ssl_cert_folder : Optional[Path]
59
+ SSL Certification folder
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ node_client: NodeClient,
65
+ mqtt_broker_host: str,
66
+ mqtt_broker_port: int,
67
+ light_service_port: int,
68
+ topics: List[str],
69
+ dataset_manager: DatasetManager,
70
+ metrics_collector: MetricsCollectorBase,
71
+ stop_callback: Callable[[], Awaitable[None]],
72
+ ssl_cert_folder: Optional[Path] = None,
73
+ ping_interval: int = TIMEOUT,
74
+ max_concurrent_tasks: int = 2,
75
+ task_timeout: int = 3600,
76
+ ) -> None:
77
+ self.tracer = Tracer(getLogger(__name__))
78
+ self.node_client = node_client
79
+ self.dataset_manager = dataset_manager
80
+ self.metrics_collector = metrics_collector
81
+ self.stop_callback = stop_callback
82
+ self.ping_interval = ping_interval
83
+ self.max_concurrent_tasks = max_concurrent_tasks
84
+ self.task_timeout = task_timeout
85
+
86
+ MqttBase.__init__(
87
+ self,
88
+ mqtt_broker_host=mqtt_broker_host,
89
+ mqtt_broker_port=mqtt_broker_port,
90
+ topics=topics,
91
+ client_id=ID(node_client.metadata.get("authorization", "node_client")),
92
+ ssl_cert_folder=ssl_cert_folder,
93
+ )
94
+
95
+ self.tracer.debug("Trying to connect to docker daemon...")
96
+ TaskManager.__init__(self, light_service_port)
97
+ self.tracer.info("Connected to docker daemon !")
98
+
99
+ async def clean(self): # TODO : use __del__
100
+ """Delete the temporary folder and stop all containers"""
101
+ self.tracer.debug("Cleaning up the temporary folder")
102
+ self.temp_folder.cleanup()
103
+
104
+ self.tracer.info("Stopping all tasks")
105
+ for task_progression in self.tasks:
106
+ try:
107
+ task_id = task_progression.task_id
108
+ swarm_id = task_progression.swarm_id
109
+
110
+ task_progression.unlink()
111
+ await self.stop_container(swarm_id, task_id)
112
+ await self.remove_container(swarm_id, task_id)
113
+ except Exception:
114
+ self.tracer.exception(
115
+ f"Error when stopping task {task_progression.task_id}"
116
+ )
117
+
118
+ self.tracer.info("Closing Docker client")
119
+ try:
120
+ await self.async_client.close()
121
+ except Exception:
122
+ self.tracer.exception("Error when closing Docker client")
123
+
124
+ async def main(self):
125
+ """
126
+ Override MqttBase.main to run task checker as a background task
127
+ alongside the MQTT message loop.
128
+ """
129
+ self.tracer.debug("Starting background task checker")
130
+ checker = asyncio.create_task(self._ping_loop())
131
+ try:
132
+ await MqttBase.main(self)
133
+ finally:
134
+ checker.cancel()
135
+ try:
136
+ await checker
137
+ except asyncio.CancelledError:
138
+ pass
139
+
140
+ async def loop_tasks(self):
141
+ """
142
+ MQTT message loop (subscribe + publish).
143
+ Task checking runs independently in the background via main().
144
+
145
+ Overrides base class to fix a race condition: the background
146
+ _ping_loop can queue messages during subscribe processing,
147
+ causing publish_task (queue.get()) to consume and remove them
148
+ from the queue. If the base class processes subscribe first
149
+ without also handling the completed publish_task, the message
150
+ is silently dropped. See issue #285.
151
+ """
152
+ while True:
153
+ try:
154
+ subscribe_task = asyncio.create_task(self.client.messages.__anext__())
155
+ publish_task = asyncio.create_task(self.queue.get())
156
+ done, _ = await asyncio.wait(
157
+ (subscribe_task, publish_task),
158
+ return_when=asyncio.FIRST_COMPLETED,
159
+ )
160
+ except asyncio.CancelledError:
161
+ subscribe_task.cancel()
162
+ publish_task.cancel()
163
+ await asyncio.gather(
164
+ subscribe_task, publish_task, return_exceptions=True
165
+ )
166
+ raise
167
+
168
+ try:
169
+ if subscribe_task in done and publish_task in done:
170
+ try:
171
+ await self._subscribe_from_task(subscribe_task)
172
+ finally:
173
+ await self._publish_from_task(publish_task)
174
+ elif subscribe_task in done:
175
+ await self._subscribe_from_task(subscribe_task)
176
+ if publish_task.done():
177
+ await self._publish_from_task(publish_task)
178
+ elif publish_task in done:
179
+ await self._publish_from_task(publish_task)
180
+ else:
181
+ self.tracer.warning("Disconnected during message iteration")
182
+ raise aiomqtt.MqttError("Disconnected during message iteration")
183
+ finally:
184
+ if not subscribe_task.done():
185
+ subscribe_task.cancel()
186
+ if not publish_task.done():
187
+ publish_task.cancel()
188
+
189
+ async def _sweep_orphans(self) -> None:
190
+ """Kill Docker containers with manta labels that are no longer tracked.
191
+
192
+ When a ``stop_swarm`` MQTT message is lost (connection drop) the
193
+ Docker container stays alive and blocks port 8080 on subsequent
194
+ deploys. This runs periodically in the background to clean up.
195
+ """
196
+ try:
197
+ docker_containers = await self.async_client.containers.list(
198
+ all=False,
199
+ filters={"label": ["com.docker.compose.project=manta_tasks"]},
200
+ )
201
+ except Exception:
202
+ return
203
+ tracked = set(self.tasks._env_ids) | self._launching
204
+
205
+ for dc in docker_containers:
206
+ labels = dc.get("Labels", {}) or {}
207
+ swarm_xid = labels.get("manta.swarm-id")
208
+ task_xid = labels.get("manta.task-id")
209
+ if swarm_xid is None or task_xid is None:
210
+ continue
211
+ tid_pair = (ID(swarm_xid).tid, ID(task_xid).tid)
212
+ if tid_pair in tracked:
213
+ continue
214
+
215
+ cid = dc["Id"][:12]
216
+ self.tracer.info(
217
+ f"Orphan sweep: killing container {cid} "
218
+ f"({swarm_xid[:8]}/{task_xid[:8]})"
219
+ )
220
+ try:
221
+ container = await self.async_client.containers.get(dc["Id"])
222
+ await container.kill()
223
+ await container.delete()
224
+ except Exception:
225
+ self.tracer.exception(f"Failed to clean up orphan container {cid}")
226
+
227
+ async def _ping_loop(self):
228
+ """
229
+ Background loop that periodically checks task statuses and
230
+ sends heartbeats. Runs independently of the MQTT connection state.
231
+ """
232
+ iteration = 0
233
+ while True:
234
+ iteration += 1
235
+ if iteration % 10 == 0:
236
+ self.tracer.info(
237
+ f"Ping loop alive: iteration {iteration} "
238
+ f"(tasks={len(self.tasks._env_ids)}, queue={len(self.task_queue)})"
239
+ )
240
+ if iteration % 15 == 0:
241
+ try:
242
+ await self._sweep_orphans()
243
+ except Exception:
244
+ self.tracer.exception("Orphan sweep failed")
245
+ try:
246
+ await self._ping()
247
+ except asyncio.CancelledError:
248
+ break
249
+ except Exception:
250
+ self.tracer.exception("Unhandled error in ping loop, continuing...")
251
+
252
+ async def _ping(self):
253
+ """
254
+ Ping the Manager
255
+ """
256
+ await asyncio.sleep(self.ping_interval)
257
+ updated = await self.check_tasks()
258
+ if len(updated) > 0:
259
+ for task_progression in updated:
260
+ if task_progression.task_status == TaskStatus.STOPPED:
261
+ self.tracer.info(
262
+ f"Task {task_progression.task_id} STOPPED, collecting logs"
263
+ )
264
+ try:
265
+ date, processed_logs = await self.get_task_logs(
266
+ task_progression
267
+ )
268
+ self.tracer.info(
269
+ f"Sending logs to the Manager for task {task_progression.task_id}"
270
+ )
271
+ await self.send_task_logs(date, processed_logs)
272
+ except MantaGRPCError as e:
273
+ self.tracer.exception(
274
+ f"gRPCError when updating finished tasks: {e}"
275
+ )
276
+ task_progression.task_status = TaskStatus.FAILED
277
+ except MantaError as manta_error:
278
+ self.tracer.exception("Error updating finished tasks.")
279
+ manta_error.metadata = self.tracer.metadata
280
+ task_progression.task_status = TaskStatus.FAILED
281
+ await self.node_client.send_error(manta_error)
282
+ except Exception as exc:
283
+ message = f"Error updating finished tasks: {repr(exc)}"
284
+ self.tracer.exception(message)
285
+ task_progression.task_status = TaskStatus.FAILED
286
+ await self.node_client.send_error(
287
+ MantaNodeError(message, metadata=self.tracer.metadata)
288
+ )
289
+ else:
290
+ self.tracer.info(
291
+ f"Publishing status for task {task_progression.task_id}: "
292
+ f"{task_progression.task_status}"
293
+ )
294
+ try:
295
+ await self.publish_task_update(task_progression.task_update)
296
+ self.tracer.info(
297
+ f"Status published for task {task_progression.task_id}: "
298
+ f"{task_progression.task_status}"
299
+ )
300
+ except Exception as exc:
301
+ message = f"Error updating active tasks: {repr(exc)}"
302
+ self.tracer.exception("Error updating active tasks.")
303
+ await self.node_client.send_error(
304
+ MantaNodeError(message, metadata=self.tracer.metadata)
305
+ )
306
+ else:
307
+ await self.ping()
308
+
309
+ # Option B: periodic log flush for active RUNNING tasks
310
+ now = datetime.now(timezone.utc)
311
+ for tp in self.tasks.active_tasks():
312
+ if tp.task_status != TaskStatus.RUNNING:
313
+ continue
314
+ elapsed = (now - tp.last_log_collected).total_seconds()
315
+ if elapsed >= LOG_FLUSH_INTERVAL:
316
+ try:
317
+ await self._flush_task_logs(tp)
318
+ except Exception:
319
+ self.tracer.exception(
320
+ f"Periodic log flush failed for task {tp.task_id}"
321
+ )
322
+
323
+ async def ping(self):
324
+ """
325
+ Ping the Manager
326
+ """
327
+ try:
328
+ metrics = await self.metrics_collector.collect_all_metrics()
329
+ await self.publish_message(
330
+ "manager/set_system_summary",
331
+ MqMetricsSnapshot(
332
+ token=self.node_client.secured_token, snapshot=metrics
333
+ ),
334
+ )
335
+ except MantaGRPCError as e:
336
+ self.tracer.exception(f"Error when pinging the Manager: {e}")
337
+ except MantaError as e:
338
+ self.tracer.exception(f"Error when pinging the Manager: {e}")
339
+ await self.node_client.send_error(e)
340
+
341
+ async def process_message(self, message: aiomqtt.Message):
342
+ """
343
+ Handle messages from MQTT
344
+
345
+ Parameters
346
+ ----------
347
+ message : aiomqtt.Message
348
+ Message
349
+ """
350
+ try:
351
+ self.tracer.clean_metadata()
352
+ key = str(message.topic).split("/")[-1]
353
+ payload = cast(bytes, message.payload)
354
+
355
+ if key == "deploy_task":
356
+ await self.deploy_task(payload)
357
+ elif key == "stop_task":
358
+ task_update = await self.stop_task(payload)
359
+ if task_update is not None:
360
+ await self.publish_task_update(task_update)
361
+ elif key == "stop_swarm":
362
+ await self.stop_swarm(payload)
363
+ elif key == "collect_information":
364
+ await self.collect_information(payload)
365
+ elif key == "stop_node":
366
+ await self.stop_node(payload)
367
+ else:
368
+ self.tracer.error(f"Not allowed method: {key}")
369
+ raise MantaMQTTError(
370
+ f"Not allowed method: {key}", metadata=self.tracer.metadata
371
+ )
372
+ except MantaGRPCError as e:
373
+ self.tracer.exception(f"Error sending message to the Manager: {e}")
374
+ except MantaError as manta_error:
375
+ self.tracer.exception(f"Error when handling message: {manta_error}")
376
+ await self.node_client.send_error(manta_error)
377
+
378
+ async def publish_task_update(self, task_update: TaskUpdate):
379
+ """
380
+ Publish a task update to the Manager through MQTT
381
+ Message broker
382
+
383
+ Parameters
384
+ ----------
385
+ task_update : TaskUpdate
386
+ Task Update
387
+ """
388
+ await self.publish_message(
389
+ "manager/update_task_status",
390
+ MqTaskUpdate(token=self.node_client.secured_token, update=task_update),
391
+ )
392
+
393
+ async def send_task_logs(self, date: datetime, processed_logs: bytes):
394
+ """
395
+ Send task logs to the Manager
396
+
397
+ Parameters
398
+ ----------
399
+ date : datetime
400
+ Date when logs where created
401
+ processed_logs : bytes
402
+ Logs prepared from tracer
403
+ """
404
+
405
+ async def chunk_logs(content: bytes) -> AsyncIterable[Logs]:
406
+ data_stream = io.BytesIO(content)
407
+ while chunk := data_stream.read(CHUNK_SIZE):
408
+ yield Logs(content=chunk, timestamp=date)
409
+
410
+ await self.node_client.add_task_logs(chunk_logs(processed_logs))
411
+
412
+ async def stop_node(self, _payload: bytes):
413
+ """
414
+ Stop the node and prepare for graceful shutdown
415
+
416
+ Parameters
417
+ ----------
418
+ payload : bytes
419
+ Node ID to stop
420
+ """
421
+ await self.stop_callback()
422
+
423
+ async def on_task_setup_failed(self, task: MqttTask, exc: Exception):
424
+ """
425
+ Publish a FAILED task update when task setup fails.
426
+
427
+ Parameters
428
+ ----------
429
+ task : MqttTask
430
+ Task that failed to setup
431
+ exc : Exception
432
+ The exception that caused the failure
433
+ """
434
+ task_update = TaskUpdate(
435
+ swarm_id=task.swarm_id,
436
+ node_id=self.tracer.metadata.node_id,
437
+ task_id=task.task_id,
438
+ iteration=task.iteration,
439
+ circular=task.circular,
440
+ task_status=TaskStatus.FAILED,
441
+ )
442
+ try:
443
+ await self.publish_task_update(task_update)
444
+ self.tracer.info(
445
+ f"Published FAILED status for task {task.task_id} after setup failure"
446
+ )
447
+ except Exception as publish_exc:
448
+ self.tracer.exception(
449
+ f"Failed to publish FAILED status for task {task.task_id}: {repr(publish_exc)}"
450
+ )