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
@@ -0,0 +1,616 @@
1
+ """Docker image builder module for BlackAnt SDK."""
2
+
3
+ import docker
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Optional, Dict, Any, Iterator
9
+ from datetime import datetime
10
+
11
+ from blackant.utils.logging import get_logger
12
+ from blackant.exceptions import BlackAntDockerError
13
+ from blackant.config.docker_config import get_docker_config
14
+
15
+
16
+ class DockerImageBuilder:
17
+ """Remote Docker image builder class for BlackAnt SDK.
18
+
19
+ Implements remote Docker build using BlackAnt infrastructure as per
20
+ the architecture specification. Supports both nginx proxy (recommended)
21
+ and direct TLS connection to remote Docker daemon.
22
+ """
23
+
24
+ def __init__(self, auth=None, registry_url: str = None):
25
+ """Initialize remote Docker builder.
26
+
27
+ Args:
28
+ auth: BlackAntAuth instance for JWT token authentication
29
+ registry_url: Docker registry URL (if None, loaded from config)
30
+ """
31
+ self.logger = get_logger("docker-builder")
32
+ self.config = get_docker_config()
33
+ self.auth = auth
34
+ self.registry_url = registry_url or self.config.registry.url
35
+
36
+ # Initialize remote Docker client
37
+ self._init_remote_docker_client()
38
+
39
+ def _init_remote_docker_client(self):
40
+ """Initialize Docker client with remote daemon connection.
41
+
42
+ Supports two modes:
43
+ 1. Nginx proxy with JWT token authentication (recommended)
44
+ 2. Direct TLS connection to Docker daemon
45
+
46
+ Raises:
47
+ BlackAntDockerError: If remote Docker connection fails
48
+ """
49
+ base_url = self.config.daemon.base_url
50
+
51
+ try:
52
+ # Mode 1: Nginx proxy with JWT token authentication
53
+ if self.config.daemon.use_nginx_proxy:
54
+ # IMPORTANT: Create APIClient with explicit version to bypass auto-detection
55
+ # Auto-detection calls version() during __init__() which requires JWT auth
56
+ from docker import APIClient
57
+
58
+ # Get JWT token if available
59
+ token = None
60
+ if self.auth:
61
+ token = self.auth.get_token()
62
+ if not token:
63
+ self.logger.warning("No JWT token available for Docker daemon authentication")
64
+
65
+ # Check if we should disable SSL verification (for dev environments)
66
+ should_disable_ssl_verify = (
67
+ "dev.blackant.app" in base_url or
68
+ "localhost" in base_url or
69
+ "127.0.0.1" in base_url
70
+ )
71
+
72
+ # Disable SSL warnings for dev environments
73
+ if should_disable_ssl_verify:
74
+ import urllib3
75
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
76
+ self.logger.debug(f"SSL verification disabled for dev environment: {base_url}")
77
+
78
+ # Create TLS config for HTTPS connections in dev environment
79
+ tls_config = None
80
+ if "https://" in base_url and should_disable_ssl_verify:
81
+ from docker import tls
82
+ tls_config = tls.TLSConfig(verify=False)
83
+ self.logger.debug("TLS config created with SSL verification disabled")
84
+
85
+ # Create APIClient with explicit API version (bypasses auto-detection)
86
+ api_client = APIClient(
87
+ base_url=base_url,
88
+ timeout=self.config.daemon.timeout,
89
+ version='1.47', # Explicit version prevents auth-required version() call during init
90
+ tls=tls_config # SSL verification disabled for dev environments
91
+ )
92
+
93
+ # Add JWT token for subsequent API calls
94
+ if token:
95
+ api_client.headers.update({
96
+ 'Authorization': f'Bearer {token}'
97
+ })
98
+ self.logger.debug("JWT token injected into API client headers")
99
+
100
+ # Wrap APIClient in a DockerClient for high-level operations
101
+ # Use from_env() as dummy then replace api attribute
102
+ self.docker_client = object.__new__(docker.DockerClient)
103
+ self.docker_client.api = api_client
104
+
105
+ # Initialize collections manually
106
+ from docker.models.containers import ContainerCollection
107
+ from docker.models.images import ImageCollection
108
+ from docker.models.networks import NetworkCollection
109
+ from docker.models.volumes import VolumeCollection
110
+ from docker.models.services import ServiceCollection
111
+
112
+ object.__setattr__(self.docker_client, '_containers', ContainerCollection(client=self.docker_client))
113
+ object.__setattr__(self.docker_client, '_images', ImageCollection(client=self.docker_client))
114
+ object.__setattr__(self.docker_client, '_networks', NetworkCollection(client=self.docker_client))
115
+ object.__setattr__(self.docker_client, '_volumes', VolumeCollection(client=self.docker_client))
116
+ object.__setattr__(self.docker_client, '_services', ServiceCollection(client=self.docker_client))
117
+
118
+ if token:
119
+ self.logger.info(f"Remote Docker client initialized via nginx proxy with JWT: {base_url}")
120
+ else:
121
+ self.logger.info(f"Remote Docker client initialized via nginx proxy (no auth): {base_url}")
122
+
123
+ # Mode 2: Direct TLS connection
124
+ else:
125
+ tls_config = self.config.daemon.get_tls_config()
126
+
127
+ self.docker_client = docker.DockerClient(
128
+ base_url=base_url,
129
+ tls=tls_config,
130
+ timeout=self.config.daemon.timeout
131
+ )
132
+
133
+ self.logger.info(f"Remote Docker client initialized with TLS: {base_url}")
134
+
135
+ # Test connection to remote Docker daemon
136
+ try:
137
+ version_info = self.docker_client.version()
138
+ self.logger.info(f"Connected to remote Docker daemon version: {version_info.get('Version', 'unknown')}")
139
+ self.logger.debug(f"Docker API version: {version_info.get('ApiVersion', 'unknown')}")
140
+ except Exception as ping_error:
141
+ self.logger.warning(f"Could not verify remote Docker connection: {ping_error}")
142
+ self.logger.warning("Continuing anyway - build operations might still work")
143
+
144
+ except Exception as e:
145
+ self.logger.error(f"Failed to initialize remote Docker client: {e}")
146
+ raise BlackAntDockerError(f"Remote Docker client initialization failed: {e}")
147
+
148
+ def build_service(self,
149
+ service_name: str,
150
+ impl_path: str = "src/calculation/impl",
151
+ dockerfile_path: str = "Dockerfile",
152
+ tag: Optional[str] = None,
153
+ push_to_registry: bool = True) -> Dict[str, Any]:
154
+ """Build and push service Docker image on REMOTE Docker daemon.
155
+
156
+ The build context is automatically uploaded to the remote Docker daemon
157
+ by docker-py SDK as a tar.gz archive. The build executes on BlackAnt
158
+ infrastructure, ensuring the source code never leaves the platform.
159
+
160
+ Args:
161
+ service_name: Service name
162
+ impl_path: Implementation folder path (will be uploaded)
163
+ dockerfile_path: Dockerfile path (will be uploaded)
164
+ tag: Image tag (default: timestamp)
165
+ push_to_registry: Push to registry
166
+
167
+ Returns:
168
+ dict: Build result information
169
+ {
170
+ "image_name": "env.blackant.app/systemdevelopers/service_name",
171
+ "tag": "v1.0.0-20250105-123456",
172
+ "image_id": "sha256:...",
173
+ "size": 125000000,
174
+ "pushed": True,
175
+ "registry_url": "env.blackant.app",
176
+ "build_location": "remote",
177
+ "remote_daemon": "http://localhost"
178
+ }
179
+
180
+ Raises:
181
+ BlackAntDockerError: Build or push error
182
+ """
183
+
184
+ # 1. Generate tag if not provided
185
+ if not tag:
186
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
187
+ tag = f"v1.0.0-{timestamp}"
188
+
189
+ # 2. Build full image name using config
190
+ full_image = self.config.get_full_image_name(service_name, tag)
191
+
192
+ self.logger.info(f"Building image on REMOTE Docker daemon: {full_image}")
193
+ self.logger.info(f"Remote daemon: {self.config.daemon.base_url}")
194
+
195
+ # 3. Input validation
196
+ self._validate_build_inputs(service_name, impl_path, dockerfile_path)
197
+
198
+ try:
199
+ # 4. Prepare build context
200
+ build_context = self._prepare_build_context(impl_path, dockerfile_path)
201
+
202
+ # 5. Build Docker image
203
+ image, build_logs = self._build_image(full_image, build_context, service_name)
204
+
205
+ # 6. Push to registry
206
+ pushed = False
207
+ if push_to_registry:
208
+ pushed = self._push_to_registry(full_image)
209
+
210
+ # 7. Cleanup local build context
211
+ try:
212
+ shutil.rmtree(build_context, ignore_errors=True)
213
+ self.logger.debug("Local build context cleaned up")
214
+ except Exception as cleanup_error:
215
+ self.logger.warning(f"Failed to cleanup build context: {cleanup_error}")
216
+
217
+ # 8. Assemble result
218
+ result = {
219
+ "image_name": f"{self.registry_url}/{self.config.registry.namespace}/{service_name}",
220
+ "tag": tag,
221
+ "full_image": full_image,
222
+ "image_id": image.id,
223
+ "size": image.attrs.get('Size', 0),
224
+ "pushed": pushed,
225
+ "registry_url": self.registry_url,
226
+ "created": image.attrs.get('Created'),
227
+ "labels": image.labels or {},
228
+ "build_location": "remote", # Indicates remote build
229
+ "remote_daemon": self.config.daemon.base_url, # Remote daemon URL
230
+ "use_nginx_proxy": self.config.daemon.use_nginx_proxy # Connection mode
231
+ }
232
+
233
+ self.logger.info(f"Remote build completed successfully: {service_name}")
234
+ return result
235
+
236
+ except docker.errors.BuildError as e:
237
+ error_msg = f"Docker build failed for {service_name}: {e}"
238
+ self.logger.error(error_msg)
239
+ raise BlackAntDockerError(error_msg)
240
+ except Exception as e:
241
+ error_msg = f"Unexpected error during build of {service_name}: {e}"
242
+ self.logger.error(error_msg)
243
+ raise BlackAntDockerError(error_msg)
244
+
245
+ def _validate_build_inputs(self, service_name: str, impl_path: str, dockerfile_path: str) -> None:
246
+ """Validate build input parameters.
247
+
248
+ Args:
249
+ service_name: Service name
250
+ impl_path: Implementation path
251
+ dockerfile_path: Dockerfile path
252
+
253
+ Raises:
254
+ BlackAntDockerError: Validation error
255
+ """
256
+ if not service_name or not service_name.strip():
257
+ raise BlackAntDockerError("Service name cannot be empty")
258
+
259
+ if not service_name.replace('-', '').replace('_', '').isalnum():
260
+ raise BlackAntDockerError("Service name must contain only alphanumeric characters, hyphens, and underscores")
261
+
262
+ impl_path_obj = Path(impl_path)
263
+ if not impl_path_obj.exists():
264
+ raise BlackAntDockerError(f"Implementation path does not exist: {impl_path}")
265
+
266
+ if not impl_path_obj.is_dir():
267
+ raise BlackAntDockerError(f"Implementation path is not a directory: {impl_path}")
268
+
269
+ dockerfile_path_obj = Path(dockerfile_path)
270
+ if not dockerfile_path_obj.exists():
271
+ raise BlackAntDockerError(f"Dockerfile does not exist: {dockerfile_path}")
272
+
273
+ if not dockerfile_path_obj.is_file():
274
+ raise BlackAntDockerError(f"Dockerfile path is not a file: {dockerfile_path}")
275
+
276
+ def _prepare_build_context(self, impl_path: str, dockerfile_path: str) -> str:
277
+ """Prepare build context.
278
+
279
+ Args:
280
+ impl_path: Implementation folder
281
+ dockerfile_path: Dockerfile path
282
+
283
+ Returns:
284
+ str: Temporary build context path
285
+
286
+ Raises:
287
+ BlackAntDockerError: Context preparation error
288
+ """
289
+ try:
290
+ # Create temporary build context
291
+ temp_context = tempfile.mkdtemp(prefix="blackant_build_")
292
+ context_path = Path(temp_context)
293
+
294
+ self.logger.debug(f"Created build context: {context_path}")
295
+
296
+ # Determine project root (where Dockerfile is located)
297
+ dockerfile_src = Path(dockerfile_path).resolve()
298
+ project_root = dockerfile_src.parent
299
+
300
+ # Copy Dockerfile
301
+ dockerfile_dst = context_path / "Dockerfile"
302
+ shutil.copy2(dockerfile_src, dockerfile_dst)
303
+
304
+ # Detect build mode: full project or simple build
305
+ src_path = project_root / "src"
306
+ has_src_directory = src_path.exists() and src_path.is_dir()
307
+
308
+ if has_src_directory:
309
+ # FULL PROJECT MODE (e.g., test_full_state_machine_e2e.py)
310
+ # Copy entire src/ directory - contains everything including impl
311
+ shutil.copytree(src_path, context_path / "src", dirs_exist_ok=True)
312
+ self.logger.debug("Full project mode: Copied src/ directory")
313
+
314
+ # Copy project-level files
315
+ requirements_files = ["requirements.txt", "requirements.debug.txt"]
316
+ for req_file in requirements_files:
317
+ req_src = project_root / req_file
318
+ if req_src.exists():
319
+ shutil.copy2(req_src, context_path / req_file)
320
+ self.logger.debug(f"Copied {req_file}")
321
+
322
+ entrypoint_src = project_root / "docker-entrypoint.sh"
323
+ if entrypoint_src.exists():
324
+ shutil.copy2(entrypoint_src, context_path / "docker-entrypoint.sh")
325
+ self.logger.debug("Copied docker-entrypoint.sh")
326
+
327
+ else:
328
+ # SIMPLE BUILD MODE (e.g., test_remote_docker_build.py)
329
+ # Copy only impl/ directory - minimal build context
330
+ impl_src = Path(impl_path).resolve()
331
+ impl_dst = context_path / "impl"
332
+ if impl_src.exists() and impl_src.is_dir():
333
+ shutil.copytree(impl_src, impl_dst, dirs_exist_ok=True)
334
+ self.logger.debug(f"Simple build mode: Copied impl/ from {impl_src}")
335
+ else:
336
+ raise BlackAntDockerError(f"Implementation path not found: {impl_path}")
337
+
338
+ # Create .dockerignore file if it doesn't exist
339
+ dockerignore_path = context_path / ".dockerignore"
340
+ if not dockerignore_path.exists():
341
+ dockerignore_content = """
342
+ __pycache__/
343
+ *.pyc
344
+ *.pyo
345
+ *.pyd
346
+ .git/
347
+ .gitignore
348
+ *.md
349
+ .pytest_cache/
350
+ .coverage
351
+ """
352
+ dockerignore_path.write_text(dockerignore_content.strip())
353
+
354
+ self.logger.debug(f"Build context prepared with {len(list(context_path.rglob('*')))} files")
355
+ return str(context_path)
356
+
357
+ except Exception as e:
358
+ error_msg = f"Failed to prepare build context: {e}"
359
+ self.logger.error(error_msg)
360
+ raise BlackAntDockerError(error_msg)
361
+
362
+ def _build_image(self, full_image: str, build_context: str, service_name: str) -> tuple:
363
+ """Execute Docker image build on REMOTE daemon.
364
+
365
+ The docker-py SDK automatically handles:
366
+ 1. Creating tar.gz archive from build_context path
367
+ 2. Uploading to remote Docker daemon via configured connection
368
+ 3. Executing build on remote infrastructure
369
+ 4. Streaming build logs back to client
370
+
371
+ Args:
372
+ full_image: Full image name with tag
373
+ build_context: LOCAL build context path (will be uploaded)
374
+ service_name: Service name
375
+
376
+ Returns:
377
+ tuple: (image object, build logs)
378
+
379
+ Raises:
380
+ docker.errors.BuildError: Build error
381
+ """
382
+ self.logger.info(f"Starting REMOTE Docker build for {service_name}")
383
+ self.logger.info(f"Uploading build context to remote daemon...")
384
+
385
+ # Build arguments using config
386
+ build_args = self.config.get_build_args(service_name, {
387
+ "BUILD_DATE": datetime.now().isoformat(),
388
+ "CALCULATION_NAME": service_name # Required by Dockerfile
389
+ })
390
+
391
+ # Build labels using config
392
+ labels = self.config.get_build_labels(service_name)
393
+
394
+ # Execute REMOTE Docker build
395
+ # The docker-py SDK automatically:
396
+ # - Creates tar.gz archive from build_context
397
+ # - Uploads to remote Docker daemon
398
+ # - Executes build remotely
399
+ # - Streams logs back
400
+ image, build_logs = self.docker_client.images.build(
401
+ path=build_context, # LOCAL path - SDK uploads it automatically!
402
+ tag=full_image,
403
+ rm=self.config.build.rm,
404
+ forcerm=self.config.build.forcerm,
405
+ pull=self.config.build.pull,
406
+ buildargs=build_args,
407
+ labels=labels,
408
+ timeout=self.config.build.timeout,
409
+ platform="linux/amd64" # Ensure consistent platform
410
+ )
411
+
412
+ # Process build logs
413
+ for log in build_logs:
414
+ if 'stream' in log:
415
+ log_line = log['stream'].strip()
416
+ if log_line:
417
+ self.logger.debug(f"Remote build: {log_line}")
418
+ elif 'error' in log:
419
+ self.logger.error(f"Remote build error: {log['error']}")
420
+
421
+ self.logger.info(f"Remote image built successfully: {image.id[:12]}")
422
+ return image, build_logs
423
+
424
+ def _push_to_registry(self, image_name: str) -> bool:
425
+ """Push image to Docker registry.
426
+
427
+ If SDK Services is configured, delegates push to sdk-services backend.
428
+ Otherwise, pushes directly using Docker client with local credentials.
429
+
430
+ Args:
431
+ image_name: Full image name with tag
432
+
433
+ Returns:
434
+ bool: Successful push
435
+ """
436
+ # Check if SDK Services should handle the push
437
+ if self.config.sdk_services.use_for_registry and self.config.sdk_services.url:
438
+ return self._push_via_sdk_services(image_name)
439
+
440
+ # Direct push via Docker client
441
+ return self._push_direct(image_name)
442
+
443
+ def _push_via_sdk_services(self, image_name: str) -> bool:
444
+ """Push image via SDK Services endpoint.
445
+
446
+ Delegates push to sdk-services backend, keeping registry credentials server-side.
447
+
448
+ Args:
449
+ image_name: Full image name with tag
450
+
451
+ Returns:
452
+ bool: Successful push
453
+ """
454
+ try:
455
+ import requests
456
+
457
+ push_url = self.config.sdk_services.registry_push_url
458
+ self.logger.info(f"Pushing image via SDK Services: {push_url}")
459
+
460
+ # Get auth token if available
461
+ headers = {"Content-Type": "application/json"}
462
+ if self.auth:
463
+ token = self.auth.get_token()
464
+ if token:
465
+ headers["Authorization"] = f"Bearer {token}"
466
+
467
+ # Call SDK Services endpoint
468
+ response = requests.post(
469
+ push_url,
470
+ json={"image_name": image_name},
471
+ headers=headers,
472
+ timeout=self.config.sdk_services.timeout,
473
+ verify=False # For dev environments with self-signed certs
474
+ )
475
+
476
+ if response.status_code == 200:
477
+ result = response.json()
478
+ if result.get("success"):
479
+ self.logger.info(f"Image pushed via SDK Services: {image_name}")
480
+ return True
481
+ else:
482
+ error_msg = result.get("message", "Unknown error")
483
+ self.logger.error(f"SDK Services push failed: {error_msg}")
484
+ return False
485
+ else:
486
+ self.logger.error(f"SDK Services push failed with status {response.status_code}")
487
+ return False
488
+
489
+ except Exception as e:
490
+ self.logger.error(f"SDK Services push error: {e}")
491
+ return False
492
+
493
+ def _push_direct(self, image_name: str) -> bool:
494
+ """Push image directly via Docker client.
495
+
496
+ Uses local registry credentials from configuration.
497
+
498
+ Args:
499
+ image_name: Full image name with tag
500
+
501
+ Returns:
502
+ bool: Successful push
503
+ """
504
+ try:
505
+ self.logger.info(f"Pushing image to registry: {image_name}")
506
+
507
+ # Registry login if credentials available
508
+ credentials = self.config.get_registry_credentials()
509
+ if credentials:
510
+ self.logger.debug(f"Logging in to registry: {self.registry_url}")
511
+ self.docker_client.login(**credentials)
512
+
513
+ # Execute push
514
+ push_response = self.docker_client.images.push(
515
+ image_name,
516
+ stream=True,
517
+ decode=True
518
+ )
519
+
520
+ # Process push logs
521
+ for line in push_response:
522
+ if 'status' in line:
523
+ status = line['status']
524
+ if 'id' in line:
525
+ self.logger.debug(f"Push {line['id']}: {status}")
526
+ else:
527
+ self.logger.debug(f"Push: {status}")
528
+
529
+ if 'error' in line:
530
+ error_msg = f"Push error: {line['error']}"
531
+ self.logger.error(error_msg)
532
+ raise BlackAntDockerError(error_msg)
533
+
534
+ if 'errorDetail' in line:
535
+ error_detail = line['errorDetail'].get('message', 'Unknown error')
536
+ error_msg = f"Push error detail: {error_detail}"
537
+ self.logger.error(error_msg)
538
+ raise BlackAntDockerError(error_msg)
539
+
540
+ self.logger.info(f"Image pushed successfully: {image_name}")
541
+ return True
542
+
543
+ except docker.errors.APIError as e:
544
+ error_msg = f"Docker API error during push: {e}"
545
+ self.logger.error(error_msg)
546
+ return False
547
+ except Exception as e:
548
+ error_msg = f"Unexpected error during push: {e}"
549
+ self.logger.error(error_msg)
550
+ return False
551
+
552
+ def list_local_images(self, service_prefix: str = None) -> list:
553
+ """List local Docker images.
554
+
555
+ Args:
556
+ service_prefix: Service name prefix for filtering
557
+
558
+ Returns:
559
+ list: List of image information
560
+ """
561
+ try:
562
+ images = self.docker_client.images.list()
563
+ result = []
564
+
565
+ for image in images:
566
+ for tag in image.tags:
567
+ if service_prefix and service_prefix not in tag:
568
+ continue
569
+
570
+ result.append({
571
+ "id": image.id,
572
+ "tag": tag,
573
+ "created": image.attrs.get('Created'),
574
+ "size": image.attrs.get('Size', 0),
575
+ "labels": image.labels or {}
576
+ })
577
+
578
+ return result
579
+
580
+ except Exception as e:
581
+ self.logger.error(f"Failed to list local images: {e}")
582
+ return []
583
+
584
+ def remove_image(self, image_name: str, force: bool = False) -> bool:
585
+ """Remove Docker image.
586
+
587
+ Args:
588
+ image_name: Image name or ID
589
+ force: Force removal
590
+
591
+ Returns:
592
+ bool: Successful removal
593
+ """
594
+ try:
595
+ self.docker_client.images.remove(image_name, force=force)
596
+ self.logger.info(f"Image removed: {image_name}")
597
+ return True
598
+
599
+ except docker.errors.ImageNotFound:
600
+ self.logger.warning(f"Image not found: {image_name}")
601
+ return False
602
+ except Exception as e:
603
+ self.logger.error(f"Failed to remove image {image_name}: {e}")
604
+ return False
605
+
606
+ def __enter__(self):
607
+ """Context manager entry."""
608
+ return self
609
+
610
+ def __exit__(self, exc_type, exc_val, exc_tb):
611
+ """Context manager exit - cleanup resources."""
612
+ try:
613
+ if hasattr(self, 'docker_client'):
614
+ self.docker_client.close()
615
+ except Exception as e:
616
+ self.logger.warning(f"Error closing Docker client: {e}")