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.
- blackant/__init__.py +31 -0
- blackant/auth/__init__.py +10 -0
- blackant/auth/blackant_auth.py +518 -0
- blackant/auth/keycloak_manager.py +363 -0
- blackant/auth/request_id.py +52 -0
- blackant/auth/role_assignment.py +443 -0
- blackant/auth/tokens.py +57 -0
- blackant/client.py +400 -0
- blackant/config/__init__.py +0 -0
- blackant/config/docker_config.py +457 -0
- blackant/config/keycloak_admin_config.py +107 -0
- blackant/docker/__init__.py +12 -0
- blackant/docker/builder.py +616 -0
- blackant/docker/client.py +983 -0
- blackant/docker/dao.py +462 -0
- blackant/docker/registry.py +172 -0
- blackant/exceptions.py +111 -0
- blackant/http/__init__.py +8 -0
- blackant/http/client.py +125 -0
- blackant/patterns/__init__.py +1 -0
- blackant/patterns/singleton.py +20 -0
- blackant/services/__init__.py +10 -0
- blackant/services/dao.py +414 -0
- blackant/services/registry.py +635 -0
- blackant/utils/__init__.py +8 -0
- blackant/utils/initialization.py +32 -0
- blackant/utils/logging.py +337 -0
- blackant/utils/request_id.py +13 -0
- blackant/utils/store.py +50 -0
- blackant_sdk-1.0.2.dist-info/METADATA +117 -0
- blackant_sdk-1.0.2.dist-info/RECORD +70 -0
- blackant_sdk-1.0.2.dist-info/WHEEL +5 -0
- blackant_sdk-1.0.2.dist-info/top_level.txt +5 -0
- calculation/__init__.py +0 -0
- calculation/base.py +26 -0
- calculation/errors.py +2 -0
- calculation/impl/__init__.py +0 -0
- calculation/impl/my_calculation.py +144 -0
- calculation/impl/simple_calc.py +53 -0
- calculation/impl/test.py +1 -0
- calculation/impl/test_calc.py +36 -0
- calculation/loader.py +227 -0
- notifinations/__init__.py +8 -0
- notifinations/mail_sender.py +212 -0
- storage/__init__.py +0 -0
- storage/errors.py +10 -0
- storage/factory.py +26 -0
- storage/interface.py +19 -0
- storage/minio.py +106 -0
- task/__init__.py +0 -0
- task/dao.py +38 -0
- task/errors.py +10 -0
- task/log_adapter.py +11 -0
- task/parsers/__init__.py +0 -0
- task/parsers/base.py +13 -0
- task/parsers/callback.py +40 -0
- task/parsers/cmd_args.py +52 -0
- task/parsers/freetext.py +19 -0
- task/parsers/objects.py +50 -0
- task/parsers/request.py +56 -0
- task/resource.py +84 -0
- task/states/__init__.py +0 -0
- task/states/base.py +14 -0
- task/states/error.py +47 -0
- task/states/idle.py +12 -0
- task/states/ready.py +51 -0
- task/states/running.py +21 -0
- task/states/set_up.py +40 -0
- task/states/tear_down.py +29 -0
- 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
|