blackant-sdk 1.0.2__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 (70) hide show
  1. blackant/__init__.py +31 -0
  2. blackant/auth/__init__.py +10 -0
  3. blackant/auth/blackant_auth.py +518 -0
  4. blackant/auth/keycloak_manager.py +363 -0
  5. blackant/auth/request_id.py +52 -0
  6. blackant/auth/role_assignment.py +443 -0
  7. blackant/auth/tokens.py +57 -0
  8. blackant/client.py +400 -0
  9. blackant/config/__init__.py +0 -0
  10. blackant/config/docker_config.py +457 -0
  11. blackant/config/keycloak_admin_config.py +107 -0
  12. blackant/docker/__init__.py +12 -0
  13. blackant/docker/builder.py +616 -0
  14. blackant/docker/client.py +983 -0
  15. blackant/docker/dao.py +462 -0
  16. blackant/docker/registry.py +172 -0
  17. blackant/exceptions.py +111 -0
  18. blackant/http/__init__.py +8 -0
  19. blackant/http/client.py +125 -0
  20. blackant/patterns/__init__.py +1 -0
  21. blackant/patterns/singleton.py +20 -0
  22. blackant/services/__init__.py +10 -0
  23. blackant/services/dao.py +414 -0
  24. blackant/services/registry.py +635 -0
  25. blackant/utils/__init__.py +8 -0
  26. blackant/utils/initialization.py +32 -0
  27. blackant/utils/logging.py +337 -0
  28. blackant/utils/request_id.py +13 -0
  29. blackant/utils/store.py +50 -0
  30. blackant_sdk-1.0.2.dist-info/METADATA +117 -0
  31. blackant_sdk-1.0.2.dist-info/RECORD +70 -0
  32. blackant_sdk-1.0.2.dist-info/WHEEL +5 -0
  33. blackant_sdk-1.0.2.dist-info/top_level.txt +5 -0
  34. calculation/__init__.py +0 -0
  35. calculation/base.py +26 -0
  36. calculation/errors.py +2 -0
  37. calculation/impl/__init__.py +0 -0
  38. calculation/impl/my_calculation.py +144 -0
  39. calculation/impl/simple_calc.py +53 -0
  40. calculation/impl/test.py +1 -0
  41. calculation/impl/test_calc.py +36 -0
  42. calculation/loader.py +227 -0
  43. notifinations/__init__.py +8 -0
  44. notifinations/mail_sender.py +212 -0
  45. storage/__init__.py +0 -0
  46. storage/errors.py +10 -0
  47. storage/factory.py +26 -0
  48. storage/interface.py +19 -0
  49. storage/minio.py +106 -0
  50. task/__init__.py +0 -0
  51. task/dao.py +38 -0
  52. task/errors.py +10 -0
  53. task/log_adapter.py +11 -0
  54. task/parsers/__init__.py +0 -0
  55. task/parsers/base.py +13 -0
  56. task/parsers/callback.py +40 -0
  57. task/parsers/cmd_args.py +52 -0
  58. task/parsers/freetext.py +19 -0
  59. task/parsers/objects.py +50 -0
  60. task/parsers/request.py +56 -0
  61. task/resource.py +84 -0
  62. task/states/__init__.py +0 -0
  63. task/states/base.py +14 -0
  64. task/states/error.py +47 -0
  65. task/states/idle.py +12 -0
  66. task/states/ready.py +51 -0
  67. task/states/running.py +21 -0
  68. task/states/set_up.py +40 -0
  69. task/states/tear_down.py +29 -0
  70. task/task.py +358 -0
blackant/docker/dao.py ADDED
@@ -0,0 +1,462 @@
1
+ """Docker as Object - High-level Docker operations abstraction.
2
+
3
+ This module provides a high-level, object-oriented interface for Docker operations,
4
+ abstracting away the complexity of the Docker Python SDK and providing a unified
5
+ API for container lifecycle management, resource management, and service orchestration.
6
+ """
7
+
8
+ import time
9
+ import random
10
+ from typing import Optional, List, Dict, Any
11
+ from dataclasses import dataclass, field
12
+
13
+ import docker
14
+ from docker.models.services import Service as DockerServiceObject
15
+ from docker.models.containers import Container as DockerContainer
16
+ from docker.models.images import Image as DockerImage
17
+
18
+ from ..http.client import HTTPClient
19
+ from ..utils.logging import get_logger
20
+
21
+
22
+ class DockerConnectionError(Exception):
23
+ """Docker connection specific exception.
24
+
25
+ Raised when Docker operations fail due to connection issues,
26
+ Docker daemon unavailability, or API errors.
27
+ """
28
+
29
+
30
+ @dataclass
31
+ class ImageConfig:
32
+ """Docker image configuration data class."""
33
+
34
+ container: str = field(default="")
35
+ name: str = field(default="")
36
+ tag: str = field(default="stable")
37
+
38
+
39
+ @dataclass
40
+ class ResourceConfig:
41
+ """Resource usage configuration data class."""
42
+
43
+ use_gpu: bool = field(default=False)
44
+ cpu_limit: int = field(default=1)
45
+ ram_limit: int = field(default=1024) # MB
46
+ disk_limit: int = field(default=1) # GB
47
+
48
+
49
+ @dataclass
50
+ class ServiceConfig:
51
+ """Service configuration data class."""
52
+
53
+ name: str = field(default="")
54
+ image: ImageConfig = field(default_factory=ImageConfig)
55
+ service_type: str = field(default="unknown")
56
+ networks: List[str] = field(default_factory=list)
57
+ environments: Dict[str, str] = field(default_factory=dict)
58
+ parent: Optional[int] = field(default=None)
59
+ resource_config: ResourceConfig = field(default_factory=ResourceConfig)
60
+ node_label: str = field(default="calculation_worker")
61
+
62
+
63
+ class DockerDAO: # pylint: disable=too-many-instance-attributes
64
+ """Docker as Object - High-level Docker operations interface.
65
+
66
+ Provides object-oriented abstraction over Docker Python SDK operations,
67
+ including container lifecycle management, image operations, node management,
68
+ and service orchestration.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ docker_host: Optional[str] = None,
74
+ registry_config: Optional[Dict] = None,
75
+ http_client: Optional[HTTPClient] = None,
76
+ ):
77
+ """Initialize Docker DAO.
78
+
79
+ Args:
80
+ docker_host: Docker daemon host URL (default: from environment)
81
+ registry_config: Docker registry configuration
82
+ http_client: HTTP client for API communication
83
+ """
84
+ self.logger = get_logger("docker.dao")
85
+ self.http_client = http_client
86
+
87
+ # Initialize Docker client
88
+ try:
89
+ if docker_host:
90
+ self.docker_client = docker.DockerClient(base_url=docker_host)
91
+ else:
92
+ self.docker_client = docker.from_env()
93
+ except docker.errors.DockerException as docker_error:
94
+ raise DockerConnectionError(
95
+ f"Cannot connect to Docker daemon: {docker_error}"
96
+ ) from docker_error
97
+
98
+ # Registry configuration
99
+ self.registry_config = registry_config or {}
100
+
101
+ # Login to registry if config provided
102
+ if self.registry_config:
103
+ self._login_to_registry()
104
+
105
+ def _login_to_registry(self):
106
+ """Login to Docker registry using provided configuration."""
107
+ try:
108
+ self.docker_client.login(
109
+ registry=self.registry_config.get("url"),
110
+ username=self.registry_config.get("username"),
111
+ password=self.registry_config.get("password"),
112
+ )
113
+ self.logger.info("Successfully logged in to Docker registry")
114
+ except docker.errors.APIError as api_error:
115
+ self.logger.error("Failed to login to Docker registry: %s", api_error)
116
+ raise DockerConnectionError(f"Registry login failed: {api_error}") from api_error
117
+
118
+ # Container Operations
119
+ def get_containers(self, all_containers: bool = True) -> List[DockerContainer]:
120
+ """Get list of Docker containers.
121
+
122
+ Args:
123
+ all_containers: Include stopped containers (default: True)
124
+
125
+ Returns:
126
+ List of Docker container objects
127
+
128
+ Raises:
129
+ DockerConnectionError: When Docker API fails
130
+ """
131
+ try:
132
+ return self.docker_client.containers.list(all=all_containers)
133
+ except docker.errors.APIError as api_error:
134
+ self.logger.error("Cannot get containers from Docker: %s", api_error)
135
+ raise DockerConnectionError(f"Failed to list containers: {api_error}") from api_error
136
+
137
+ def get_container(self, container_id: str) -> DockerContainer:
138
+ """Get Docker container by ID.
139
+
140
+ Args:
141
+ container_id: Container ID or name
142
+
143
+ Returns:
144
+ Docker container object
145
+
146
+ Raises:
147
+ DockerConnectionError: When container not found or API fails
148
+ """
149
+ try:
150
+ return self.docker_client.containers.get(container_id)
151
+ except docker.errors.NotFound as not_found_error:
152
+ raise DockerConnectionError(f"Container {container_id} not found") from not_found_error
153
+ except docker.errors.APIError as api_error:
154
+ self.logger.error("Cannot get container %s: %s", container_id, api_error)
155
+ raise DockerConnectionError(f"Failed to get container: {api_error}") from api_error
156
+
157
+ # Image Operations
158
+ def get_images(self, all_images: bool = True) -> List[DockerImage]:
159
+ """Get list of Docker images.
160
+
161
+ Args:
162
+ all_images: Include untagged images (default: True)
163
+
164
+ Returns:
165
+ List of Docker image objects
166
+ """
167
+ try:
168
+ return self.docker_client.images.list(all=all_images)
169
+ except docker.errors.APIError as api_error:
170
+ self.logger.error("Cannot get images from Docker: %s", api_error)
171
+ raise DockerConnectionError(f"Failed to list images: {api_error}") from api_error
172
+
173
+ def pull_image(self, image_name: str, tag: str = "latest") -> DockerImage:
174
+ """Pull Docker image from registry.
175
+
176
+ Args:
177
+ image_name: Image name
178
+ tag: Image tag (default: "latest")
179
+
180
+ Returns:
181
+ Docker image object
182
+ """
183
+ try:
184
+ full_image_name = f"{image_name}:{tag}"
185
+ self.logger.info("Pulling image: %s", full_image_name)
186
+ return self.docker_client.images.pull(image_name, tag=tag)
187
+ except docker.errors.NotFound as not_found_error:
188
+ raise DockerConnectionError(f"Image {image_name}:{tag} not found") from not_found_error
189
+ except docker.errors.APIError as api_error:
190
+ self.logger.error("Cannot pull image %s:%s: %s", image_name, tag, api_error)
191
+ raise DockerConnectionError(f"Failed to pull image: {api_error}") from api_error
192
+
193
+ # Node Operations (Docker Swarm)
194
+ def get_nodes(self, node_name: Optional[str] = None, node_id: Optional[str] = None) -> List:
195
+ """Get Docker Swarm nodes.
196
+
197
+ Args:
198
+ node_name: Filter by exact node name
199
+ node_id: Filter by node ID
200
+
201
+ Returns:
202
+ List of Docker node objects
203
+ """
204
+ try:
205
+ if node_name:
206
+ # Exact match filtering for node name
207
+ all_nodes = self.docker_client.nodes.list()
208
+ return [
209
+ node for node in all_nodes if node.attrs["Description"]["Hostname"] == node_name
210
+ ]
211
+ if node_id:
212
+ return self.docker_client.nodes.list(filters={"id": node_id})
213
+ return self.docker_client.nodes.list()
214
+ except docker.errors.APIError as api_error:
215
+ self.logger.error("Cannot get nodes from Docker: %s", api_error)
216
+ raise DockerConnectionError(f"Failed to list nodes: {api_error}") from api_error
217
+
218
+ def get_node(self, node_id: str):
219
+ """Get single Docker Swarm node by ID.
220
+
221
+ Args:
222
+ node_id: Node ID to retrieve
223
+
224
+ Returns:
225
+ Docker node object
226
+ """
227
+ try:
228
+ return self.docker_client.nodes.get(node_id)
229
+ except docker.errors.NotFound as not_found_error:
230
+ raise DockerConnectionError(f"Node {node_id} not found") from not_found_error
231
+ except docker.errors.APIError as api_error:
232
+ self.logger.error("Cannot get node %s: %s", node_id, api_error)
233
+ raise DockerConnectionError(f"Failed to get node: {api_error}") from api_error
234
+
235
+ def get_node_ip(self, node_id: str) -> Optional[str]:
236
+ """Get IP address of Docker Swarm node.
237
+
238
+ Args:
239
+ node_id: Node ID
240
+
241
+ Returns:
242
+ Node IP address or None if not found
243
+ """
244
+ try:
245
+ node = self.docker_client.nodes.get(node_id)
246
+
247
+ # Check manager status first, then worker status
248
+ if node.attrs.get("ManagerStatus"):
249
+ return node.attrs["ManagerStatus"]["Addr"].split(":")[0]
250
+ if node.attrs.get("Status"):
251
+ return node.attrs["Status"]["Addr"].split(":")[0]
252
+ return None
253
+
254
+ except docker.errors.NotFound:
255
+ self.logger.warning("Node %s not found", node_id)
256
+ return None
257
+ except docker.errors.APIError as api_error:
258
+ self.logger.error("Cannot get IP for node %s: %s", node_id, api_error)
259
+ raise DockerConnectionError(f"Failed to get node IP: {api_error}") from api_error
260
+
261
+ # Service Operations (Docker Swarm)
262
+ def create_service(self, service_config: ServiceConfig) -> DockerServiceObject:
263
+ """Create Docker Swarm service.
264
+
265
+ Args:
266
+ service_config: Service configuration
267
+
268
+ Returns:
269
+ Created Docker service object
270
+ """
271
+ image_name = f"{service_config.image.container}/{service_config.image.name}"
272
+ full_image = f"{image_name}:{service_config.image.tag}"
273
+
274
+ # Pull image first
275
+ self.pull_image(image_name, service_config.image.tag)
276
+
277
+ try:
278
+ # Convert resource limits
279
+ cpu_limit = service_config.resource_config.cpu_limit * 1000000000 # nanocpus
280
+ mem_limit = service_config.resource_config.ram_limit * 1000000 # bytes
281
+
282
+ service_resources = docker.types.Resources(cpu_limit=cpu_limit, mem_limit=mem_limit)
283
+
284
+ # Node selection
285
+ node_key, node_value = self._node_selector(
286
+ service_config.name, service_config.node_label
287
+ )
288
+
289
+ # Create service
290
+ docker_service = self.docker_client.services.create(
291
+ name=service_config.name,
292
+ hostname=service_config.name,
293
+ image=full_image,
294
+ env=service_config.environments,
295
+ labels={"service_type": service_config.service_type},
296
+ mode=docker.types.ServiceMode("replicated", 1),
297
+ networks=service_config.networks or ["science_module_callback_net"],
298
+ constraints=[f"node.labels.{node_key} == {node_value}"],
299
+ resources=service_resources,
300
+ log_driver="json-file",
301
+ log_driver_options={
302
+ "max-size": "10m",
303
+ "max-file": "3",
304
+ "labels": service_config.name,
305
+ },
306
+ )
307
+
308
+ self.logger.info("Service %s created: %s", service_config.name, docker_service.id)
309
+ return docker_service
310
+
311
+ except docker.errors.APIError as api_error:
312
+ self.logger.error("Cannot create service %s: %s", service_config.name, api_error)
313
+ raise DockerConnectionError(f"Failed to create service: {api_error}") from api_error
314
+
315
+ def get_service(self, service_id: str) -> DockerServiceObject:
316
+ """Get Docker service by ID.
317
+
318
+ Args:
319
+ service_id: Service ID or name
320
+
321
+ Returns:
322
+ Docker service object
323
+ """
324
+ try:
325
+ return self.docker_client.services.get(service_id)
326
+ except docker.errors.NotFound as not_found_error:
327
+ raise DockerConnectionError(f"Service {service_id} not found") from not_found_error
328
+ except docker.errors.APIError as api_error:
329
+ self.logger.error("Cannot get service %s: %s", service_id, api_error)
330
+ raise DockerConnectionError(f"Failed to get service: {api_error}") from api_error
331
+
332
+ def is_service_running(self, service_id: str) -> bool:
333
+ """Check if Docker service has running tasks.
334
+
335
+ Args:
336
+ service_id: Service ID or name
337
+
338
+ Returns:
339
+ True if service has running tasks, False otherwise
340
+ """
341
+ try:
342
+ service = self.get_service(service_id)
343
+ return self._any_task_running(service)
344
+ except DockerConnectionError:
345
+ return False
346
+
347
+ def delete_service(self, service_id: str):
348
+ """Delete Docker service.
349
+
350
+ Args:
351
+ service_id: Service ID or name
352
+ """
353
+ try:
354
+ service = self.docker_client.services.get(service_id)
355
+ service.remove()
356
+ self.logger.info("Service %s deleted", service_id)
357
+ except docker.errors.NotFound:
358
+ self.logger.warning("Service %s not found for deletion", service_id)
359
+ except docker.errors.APIError as api_error:
360
+ self.logger.error("Cannot delete service %s: %s", service_id, api_error)
361
+ raise DockerConnectionError(f"Failed to delete service: {api_error}") from api_error
362
+
363
+ # Utility Methods
364
+ def _node_selector(self, service_name: str, preferred_node: Optional[str] = None):
365
+ """Select appropriate node for service deployment.
366
+
367
+ Args:
368
+ service_name: Name of service to deploy
369
+ preferred_node: Preferred node label value
370
+
371
+ Returns:
372
+ Tuple of (label_key, label_value) for node constraint
373
+ """
374
+ selected_key = "type"
375
+ selected_value = "calculation_worker"
376
+
377
+ if preferred_node:
378
+ nodes = self.get_nodes()
379
+ if nodes:
380
+ # Check for worker_label in nodes
381
+ worker_labels = []
382
+ for node in nodes:
383
+ labels = node.attrs.get("Spec", {}).get("Labels", {})
384
+ if "worker_label" in labels:
385
+ worker_labels.append(labels["worker_label"])
386
+
387
+ if preferred_node in worker_labels:
388
+ selected_key = "worker_label"
389
+ selected_value = preferred_node
390
+ else:
391
+ self.logger.warning(
392
+ "Preferred node '%s' not available, using default '%s:%s'",
393
+ preferred_node, selected_key, selected_value
394
+ )
395
+
396
+ self.logger.info(
397
+ "Selected node constraint for '%s': %s=%s",
398
+ service_name, selected_key, selected_value
399
+ )
400
+ return selected_key, selected_value
401
+
402
+ def _any_task_running(self, docker_service: DockerServiceObject) -> bool:
403
+ """Check if any task in service is running.
404
+
405
+ Args:
406
+ docker_service: Docker service object
407
+
408
+ Returns:
409
+ True if any task is running, False otherwise
410
+ """
411
+ try:
412
+ for task in docker_service.tasks():
413
+ if task["Status"]["State"] == "running":
414
+ return True
415
+ return False
416
+ except Exception as exc:
417
+ self.logger.error("Cannot check service tasks: %s", exc)
418
+ return False
419
+
420
+ def wait_for_service_ready(self, service: DockerServiceObject, timeout: int = 300) -> bool:
421
+ """Wait for service to become ready.
422
+
423
+ Args:
424
+ service: Docker service object
425
+ timeout: Maximum wait time in seconds
426
+
427
+ Returns:
428
+ True if service became ready, False if timeout
429
+ """
430
+ self.logger.info("Waiting for service %s to become ready...", service.name)
431
+
432
+ for _ in range(timeout):
433
+ if self._any_task_running(service):
434
+ self.logger.info("Service %s is ready", service.name)
435
+ return True
436
+
437
+ sleep_time = random.uniform(0.5, 1.5)
438
+ time.sleep(sleep_time)
439
+
440
+ self.logger.warning("Service %s did not become ready in %ss", service.name, timeout)
441
+ return False
442
+
443
+ def get_swarm_info(self) -> Dict[str, Any]:
444
+ """Get Docker Swarm cluster information.
445
+
446
+ Returns:
447
+ Dictionary containing Swarm attributes
448
+ """
449
+ try:
450
+ return self.docker_client.swarm.attrs
451
+ except docker.errors.APIError as api_error:
452
+ self.logger.error("Cannot get Swarm info: %s", api_error)
453
+ raise DockerConnectionError(f"Failed to get Swarm info: {api_error}") from api_error
454
+
455
+ def __enter__(self):
456
+ """Context manager entry."""
457
+ return self
458
+
459
+ def __exit__(self, exc_type, exc_val, exc_tb):
460
+ """Context manager exit - cleanup resources."""
461
+ if hasattr(self.docker_client, "close"):
462
+ self.docker_client.close()
@@ -0,0 +1,172 @@
1
+ """Docker Registry v2 API client.
2
+
3
+ Provides high-level interface for Docker Registry v2 operations
4
+ including image manifest management, layer operations, and repository catalog.
5
+ """
6
+
7
+ from typing import Dict, Any, List
8
+
9
+ from ..auth.blackant_auth import BlackAntAuth
10
+ from ..http.client import HTTPClient, HTTPConnectionError
11
+ from ..exceptions import BlackAntDockerError
12
+ from ..utils.logging import get_logger
13
+
14
+
15
+ class DockerRegistryClient:
16
+ """Docker Registry v2 API client.
17
+
18
+ Provides operations for managing Docker images in a registry,
19
+ including manifest operations, layer management, and repository catalog.
20
+
21
+ Args:
22
+ auth (BlackAntAuth): Authentication object with credentials.
23
+ registry_url (str): Registry base URL.
24
+
25
+ Examples:
26
+ >>> auth = BlackAntAuth(user="my_name", password="xxx")
27
+ >>> registry = DockerRegistryClient(auth=auth,
28
+ ... registry_url="http://localhost:5000")
29
+ >>> catalog = registry.get_catalog()
30
+ >>> manifest = registry.get_manifest("myapp", "latest")
31
+ """
32
+
33
+ def __init__(self, auth: BlackAntAuth, registry_url: str):
34
+ """Initialize Docker Registry client.
35
+
36
+ Args:
37
+ auth: BlackAntAuth object with user credentials.
38
+ registry_url: Docker Registry base URL.
39
+ """
40
+ self.auth = auth
41
+ self.registry_url = registry_url.rstrip("/")
42
+
43
+ # Initialize HTTPClient for registry operations
44
+ self.http_client = HTTPClient(
45
+ base_url=self.registry_url,
46
+ auth_token_store=auth.token_store
47
+ )
48
+
49
+ self.logger = get_logger("docker.registry")
50
+ self.logger.info(f"DockerRegistryClient initialized for {registry_url}")
51
+
52
+ def get_catalog(self) -> List[str]:
53
+ """Get repository catalog from registry.
54
+
55
+ Returns:
56
+ List of repository names.
57
+
58
+ Raises:
59
+ BlackAntDockerError: If catalog retrieval fails.
60
+ """
61
+ try:
62
+ response = self.http_client.send_request(
63
+ endpoint="/v2/_catalog",
64
+ method="GET"
65
+ )
66
+ response.raise_for_status()
67
+
68
+ catalog_data = response.json()
69
+ repositories = catalog_data.get("repositories", [])
70
+
71
+ self.logger.debug(f"Found {len(repositories)} repositories")
72
+ return repositories
73
+
74
+ except HTTPConnectionError as error:
75
+ self.logger.error(f"Failed to get catalog: {error}")
76
+ raise BlackAntDockerError(f"Could not get catalog: {error}") from error
77
+ except Exception as error:
78
+ self.logger.error(f"Unexpected error getting catalog: {error}")
79
+ raise BlackAntDockerError(f"Catalog retrieval failed: {error}") from error
80
+
81
+ def get_tags(self, repository: str) -> List[str]:
82
+ """Get tags for a repository.
83
+
84
+ Args:
85
+ repository: Repository name.
86
+
87
+ Returns:
88
+ List of tags for the repository.
89
+
90
+ Raises:
91
+ BlackAntDockerError: If tag retrieval fails.
92
+ """
93
+ try:
94
+ response = self.http_client.send_request(
95
+ endpoint=f"/v2/{repository}/tags/list",
96
+ method="GET"
97
+ )
98
+ response.raise_for_status()
99
+
100
+ tags_data = response.json()
101
+ tags = tags_data.get("tags", [])
102
+
103
+ self.logger.debug(f"Found {len(tags)} tags for {repository}")
104
+ return tags
105
+
106
+ except HTTPConnectionError as error:
107
+ self.logger.error(f"Failed to get tags for {repository}: {error}")
108
+ raise BlackAntDockerError(f"Could not get tags: {error}") from error
109
+ except Exception as error:
110
+ self.logger.error(f"Unexpected error getting tags: {error}")
111
+ raise BlackAntDockerError(f"Tag retrieval failed: {error}") from error
112
+
113
+ def get_manifest(self, repository: str, tag: str) -> Dict[str, Any]:
114
+ """Get image manifest.
115
+
116
+ Args:
117
+ repository: Repository name.
118
+ tag: Image tag.
119
+
120
+ Returns:
121
+ Image manifest data.
122
+
123
+ Raises:
124
+ BlackAntDockerError: If manifest retrieval fails.
125
+ """
126
+ try:
127
+ # Request with proper Accept header for manifest v2
128
+ response = self.http_client.send_request(
129
+ endpoint=f"/v2/{repository}/manifests/{tag}",
130
+ method="GET"
131
+ )
132
+
133
+ # Add Accept header manually if needed
134
+ if hasattr(response, 'request'):
135
+ response.request.headers['Accept'] = (
136
+ 'application/vnd.docker.distribution.manifest.v2+json, '
137
+ 'application/vnd.docker.distribution.manifest.v1+json'
138
+ )
139
+
140
+ response.raise_for_status()
141
+
142
+ manifest = response.json()
143
+ self.logger.debug(f"Retrieved manifest for {repository}:{tag}")
144
+ return manifest
145
+
146
+ except HTTPConnectionError as error:
147
+ self.logger.error(f"Failed to get manifest: {error}")
148
+ raise BlackAntDockerError(f"Could not get manifest: {error}") from error
149
+ except Exception as error:
150
+ self.logger.error(f"Unexpected error getting manifest: {error}")
151
+ raise BlackAntDockerError(f"Manifest retrieval failed: {error}") from error
152
+
153
+ def check_health(self) -> bool:
154
+ """Check registry health.
155
+
156
+ Returns:
157
+ True if registry is healthy.
158
+
159
+ Raises:
160
+ BlackAntDockerError: If health check fails.
161
+ """
162
+ try:
163
+ response = self.http_client.send_request(
164
+ endpoint="/v2/",
165
+ method="GET"
166
+ )
167
+
168
+ # Registry should return 200 for /v2/ endpoint
169
+ return response.status_code == 200
170
+
171
+ except Exception:
172
+ return False