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/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """BlackAnt SDK package.
2
+
3
+ Main entry point for BlackAnt SDK providing Docker operations
4
+ with automatic authentication through BlackAnt platform.
5
+ """
6
+
7
+ from .client import BlackAntClient
8
+ from .auth import BlackAntAuth
9
+ from .docker import BlackAntDockerClient
10
+ from .services import ServiceRegistry
11
+ from .exceptions import (
12
+ BlackAntException,
13
+ BlackAntAuthenticationError,
14
+ BlackAntDockerError,
15
+ BlackAntConnectionError,
16
+ BlackAntConfigurationError
17
+ )
18
+
19
+ __version__ = "1.0.0"
20
+
21
+ __all__ = [
22
+ "BlackAntClient",
23
+ "BlackAntAuth",
24
+ "BlackAntDockerClient",
25
+ "ServiceRegistry",
26
+ "BlackAntException",
27
+ "BlackAntAuthenticationError",
28
+ "BlackAntDockerError",
29
+ "BlackAntConnectionError",
30
+ "BlackAntConfigurationError"
31
+ ]
@@ -0,0 +1,10 @@
1
+ """BlackAnt SDK authentication module.
2
+
3
+ Provides authentication token management and request ID tracking.
4
+ """
5
+
6
+ from .blackant_auth import BlackAntAuth
7
+ from .tokens import AuthTokenStore
8
+ from .request_id import RequestIdStore
9
+
10
+ __all__ = ["BlackAntAuth", "AuthTokenStore", "RequestIdStore"]
@@ -0,0 +1,518 @@
1
+ """BlackAnt authentication module.
2
+
3
+ Provides username/password authentication to obtain Bearer tokens
4
+ from Keycloak for BlackAnt platform services.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional, Dict, Any, List
9
+
10
+ import requests
11
+
12
+ from .tokens import AuthTokenStore
13
+ from ..exceptions import BlackAntAuthenticationError
14
+ from ..utils.logging import get_logger
15
+
16
+
17
+ class BlackAntAuth:
18
+ """BlackAnt authentication handler.
19
+
20
+ Manages username/password authentication with Keycloak to obtain
21
+ Bearer tokens. All BlackAnt services require this token.
22
+
23
+ The token is automatically injected into all HTTP requests through
24
+ Nginx proxy which validates it with Keycloak.
25
+
26
+ Args:
27
+ user (str): Username for authentication.
28
+ password (str): Password for authentication.
29
+ login_url (str, optional): Keycloak login endpoint URL.
30
+
31
+ Examples:
32
+ >>> auth = BlackAntAuth(user="my_name", password="xxx")
33
+ >>> token = auth.get_token()
34
+ >>> # Token is automatically used in subsequent API calls
35
+ """
36
+
37
+ def __init__(self, user: str = None, password: str = None,
38
+ client_id: str = None, client_secret: str = None,
39
+ login_url: Optional[str] = None):
40
+ """Initialize authentication with credentials.
41
+
42
+ Supports two authentication modes:
43
+ 1. Username/Password (OAuth2 Password Flow)
44
+ 2. Client ID/Secret (OAuth2 Client Credentials Flow)
45
+
46
+ Args:
47
+ user: Username for authentication (password flow).
48
+ password: Password for authentication (password flow).
49
+ client_id: Client ID for service account authentication (client credentials flow).
50
+ client_secret: Client secret for service account authentication (client credentials flow).
51
+ login_url: Optional login endpoint URL, defaults to environment variable.
52
+
53
+ Raises:
54
+ ValueError: If neither (user+password) nor (client_id+client_secret) provided.
55
+ """
56
+ # Validate that we have either username/password OR client_id/client_secret
57
+ has_user_pass = user and password
58
+ has_client_creds = client_id and client_secret
59
+
60
+ if not has_user_pass and not has_client_creds:
61
+ raise ValueError(
62
+ "Either (user+password) or (client_id+client_secret) must be provided"
63
+ )
64
+
65
+ if has_user_pass and has_client_creds:
66
+ raise ValueError(
67
+ "Provide either (user+password) OR (client_id+client_secret), not both"
68
+ )
69
+
70
+ self.user = user
71
+ self.password = password
72
+ self.client_id = client_id or "blackant-app"
73
+ self.client_secret = client_secret
74
+ self.login_url = login_url or os.getenv(
75
+ "BLACKANT_LOGIN_URL",
76
+ "https://dev.blackant.app/api/auth/login"
77
+ )
78
+
79
+ # Initialize attributes that are referenced later
80
+ self.realm = "master"
81
+
82
+ # Use existing AuthTokenStore for thread-safe token storage
83
+ self.token_store = AuthTokenStore()
84
+ self.request_timeout = 30.0 # Default timeout for requests
85
+ self.logger = get_logger("auth")
86
+
87
+ # Token will be stored after successful authentication
88
+ self._authenticated = False
89
+ self._auth_mode = "client_credentials" if has_client_creds else "password"
90
+
91
+ def authenticate(self) -> str:
92
+ """Authenticate with Keycloak and store access token.
93
+
94
+ Automatically selects authentication mode based on initialization:
95
+ - Username/Password flow if (user+password) provided
96
+ - Client Credentials flow if (client_id+client_secret) provided
97
+
98
+ Returns:
99
+ str: The access token obtained from Keycloak.
100
+
101
+ Raises:
102
+ BlackAntAuthenticationError: If login fails or token not received.
103
+ """
104
+ if self._auth_mode == "client_credentials":
105
+ return self.authenticate_with_client_credentials()
106
+ else:
107
+ return self.authenticate_with_password()
108
+
109
+ def authenticate_with_password(self) -> str:
110
+ """Authenticate using OAuth2 Password Flow (username/password).
111
+
112
+ Sends username/password to Keycloak login endpoint and
113
+ stores the returned Bearer token for future API calls.
114
+
115
+ Returns:
116
+ str: The access token obtained from Keycloak.
117
+
118
+ Raises:
119
+ BlackAntAuthenticationError: If login fails or token not received.
120
+ """
121
+ self.logger.info(f"Authenticating user with password flow: {self.user}")
122
+
123
+ data = {
124
+ "username": self.user,
125
+ "password": self.password
126
+ }
127
+
128
+ try:
129
+ # Send login request to Keycloak
130
+ # Determine if we should verify SSL (skip for localhost/dev)
131
+ verify_ssl = not (
132
+ "localhost" in self.login_url or
133
+ "127.0.0.1" in self.login_url or
134
+ "http://" in self.login_url or # HTTP doesn't need SSL verification
135
+ "dev.blackant.app" in self.login_url # Dev environment self-signed cert
136
+ )
137
+
138
+ response = requests.post(
139
+ self.login_url,
140
+ data=data,
141
+ verify=verify_ssl,
142
+ timeout=30
143
+ )
144
+ response.raise_for_status()
145
+
146
+ # Extract access token from response
147
+ response_data = response.json()
148
+ if "access_token" not in response_data:
149
+ raise BlackAntAuthenticationError(
150
+ f"No access_token in login response: {response_data}"
151
+ )
152
+
153
+ access_token = response_data["access_token"]
154
+
155
+ # Store token using thread-safe AuthTokenStore
156
+ self.token_store.user_token = access_token
157
+ self._authenticated = True
158
+
159
+ self.logger.info("Password flow authentication successful")
160
+ return access_token
161
+
162
+ except requests.exceptions.RequestException as error:
163
+ self.logger.error(f"Authentication request failed: {error}")
164
+ raise BlackAntAuthenticationError(f"Login request failed: {error}") from error
165
+ except (KeyError, ValueError) as error:
166
+ self.logger.error(f"Invalid login response: {error}")
167
+ raise BlackAntAuthenticationError(f"Invalid login response: {error}") from error
168
+
169
+ def authenticate_with_client_credentials(self) -> str:
170
+ """Authenticate using OAuth2 Client Credentials Flow.
171
+
172
+ Uses client_id and client_secret to obtain a service account token
173
+ from Keycloak. This is ideal for automation, CI/CD pipelines, and
174
+ server-to-server communication where no user context is needed.
175
+
176
+ Returns:
177
+ str: The access token obtained from Keycloak.
178
+
179
+ Raises:
180
+ BlackAntAuthenticationError: If authentication fails or token not received.
181
+
182
+ Example:
183
+ >>> auth = BlackAntAuth(
184
+ ... client_id="my-service-account",
185
+ ... client_secret="xyz123..."
186
+ ... )
187
+ >>> token = auth.authenticate_with_client_credentials()
188
+ >>> print(f"Service account authenticated: {token[:20]}...")
189
+ """
190
+ self.logger.info(f"Authenticating with client credentials flow: {self.client_id}")
191
+
192
+ # OAuth2 Client Credentials Flow
193
+ data = {
194
+ "grant_type": "client_credentials",
195
+ "client_id": self.client_id,
196
+ "client_secret": self.client_secret,
197
+ "scope": "openid profile email"
198
+ }
199
+
200
+ try:
201
+ # Determine if we should verify SSL (skip for localhost/dev)
202
+ verify_ssl = not (
203
+ "localhost" in self.login_url or
204
+ "127.0.0.1" in self.login_url or
205
+ "http://" in self.login_url or
206
+ "dev.blackant.app" in self.login_url
207
+ )
208
+
209
+ # Send client credentials request to Keycloak token endpoint
210
+ response = requests.post(
211
+ self.login_url,
212
+ data=data,
213
+ verify=verify_ssl,
214
+ timeout=self.request_timeout,
215
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
216
+ )
217
+ response.raise_for_status()
218
+
219
+ # Extract access token from response
220
+ response_data = response.json()
221
+ if "access_token" not in response_data:
222
+ raise BlackAntAuthenticationError(
223
+ f"No access_token in client credentials response: {response_data}"
224
+ )
225
+
226
+ access_token = response_data["access_token"]
227
+
228
+ # Store token using thread-safe AuthTokenStore
229
+ self.token_store.user_token = access_token
230
+ self._authenticated = True
231
+
232
+ self.logger.info("Client credentials authentication successful")
233
+ return access_token
234
+
235
+ except requests.exceptions.RequestException as error:
236
+ self.logger.error(f"Client credentials authentication failed: {error}")
237
+ raise BlackAntAuthenticationError(
238
+ f"Client credentials authentication failed: {error}"
239
+ ) from error
240
+ except (KeyError, ValueError) as error:
241
+ self.logger.error(f"Invalid client credentials response: {error}")
242
+ raise BlackAntAuthenticationError(
243
+ f"Invalid client credentials response: {error}"
244
+ ) from error
245
+
246
+ def get_token(self) -> str:
247
+ """Get current access token, authenticate if needed.
248
+
249
+ Returns the stored Bearer token. If not authenticated yet,
250
+ performs authentication first.
251
+
252
+ Returns:
253
+ str: The current Bearer access token.
254
+
255
+ Raises:
256
+ BlackAntAuthenticationError: If authentication fails.
257
+ """
258
+ if not self._authenticated or not self.token_store.user_token:
259
+ self.authenticate()
260
+
261
+ token = self.token_store.user_token
262
+ if not token:
263
+ raise BlackAntAuthenticationError("No token available after authentication")
264
+
265
+ return token
266
+
267
+ def is_authenticated(self) -> bool:
268
+ """Check if currently authenticated.
269
+
270
+ Returns:
271
+ bool: True if authenticated and token is available.
272
+ """
273
+ return self._authenticated and self.token_store.user_token is not None
274
+
275
+ def refresh_token(self, refresh_token: str) -> str:
276
+ """Refresh access token using refresh token.
277
+
278
+ Args:
279
+ refresh_token: The refresh token from initial login.
280
+
281
+ Returns:
282
+ New access token.
283
+
284
+ Raises:
285
+ BlackAntAuthenticationError: If token refresh fails.
286
+ """
287
+ try:
288
+ self.logger.info("Refreshing access token")
289
+
290
+ refresh_data = {
291
+ "grant_type": "refresh_token",
292
+ "refresh_token": refresh_token,
293
+ "client_id": self.client_id
294
+ }
295
+
296
+ response = requests.post(
297
+ f"{self.login_url.replace('/token', '/token')}", # Ensure correct endpoint
298
+ data=refresh_data,
299
+ timeout=self.request_timeout,
300
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
301
+ )
302
+
303
+ response.raise_for_status()
304
+ response_data = response.json()
305
+
306
+ new_access_token = response_data["access_token"]
307
+ self.token_store.user_token = new_access_token
308
+
309
+ self.logger.info("Token refresh successful")
310
+ return new_access_token
311
+
312
+ except requests.exceptions.RequestException as error:
313
+ self.logger.error(f"Token refresh failed: {error}")
314
+ raise BlackAntAuthenticationError(f"Token refresh failed: {error}") from error
315
+ except (KeyError, ValueError) as error:
316
+ self.logger.error(f"Invalid refresh response: {error}")
317
+ raise BlackAntAuthenticationError(f"Invalid refresh response: {error}") from error
318
+
319
+ def verify_token(self, token: Optional[str] = None) -> Dict[str, Any]:
320
+ """Verify token with Keycloak userinfo endpoint.
321
+
322
+ Args:
323
+ token: Token to verify, uses stored token if None.
324
+
325
+ Returns:
326
+ User information from token.
327
+
328
+ Raises:
329
+ BlackAntAuthenticationError: If token verification fails.
330
+ """
331
+ try:
332
+ verify_token = token or self.token_store.user_token
333
+ if not verify_token:
334
+ raise BlackAntAuthenticationError("No token available for verification")
335
+
336
+ if "/api/auth/login" in self.login_url:
337
+ base_url = self.login_url.replace("/api/auth/login", "")
338
+ userinfo_url = f"{base_url}/api/auth/verify"
339
+ else:
340
+ userinfo_url = self.login_url.replace(
341
+ "/protocol/openid-connect/token",
342
+ "/protocol/openid-connect/userinfo"
343
+ )
344
+
345
+ # Determine if we should verify SSL (skip for localhost/dev)
346
+ verify_ssl = not (
347
+ "localhost" in userinfo_url or
348
+ "127.0.0.1" in userinfo_url or
349
+ "http://" in userinfo_url # HTTP doesn't need SSL verification
350
+ )
351
+
352
+ response = requests.post( # POST for /api/auth/verify endpoint
353
+ userinfo_url,
354
+ headers={"Authorization": f"Bearer {verify_token}"},
355
+ timeout=self.request_timeout,
356
+ verify=verify_ssl
357
+ )
358
+
359
+ response.raise_for_status()
360
+ user_info = response.json()
361
+
362
+ self.logger.debug("Token verification successful")
363
+ return user_info
364
+
365
+ except requests.exceptions.RequestException as error:
366
+ self.logger.error(f"Token verification failed: {error}")
367
+ raise BlackAntAuthenticationError(f"Token verification failed: {error}") from error
368
+
369
+ def get_admin_token(self, admin_user: str, admin_password: str) -> str:
370
+ """Get admin token for Keycloak Admin API operations.
371
+
372
+ Args:
373
+ admin_user: Admin username.
374
+ admin_password: Admin password.
375
+
376
+ Returns:
377
+ Admin access token.
378
+
379
+ Raises:
380
+ BlackAntAuthenticationError: If admin authentication fails.
381
+ """
382
+ try:
383
+ self.logger.info(f"Getting admin token for user: {admin_user}")
384
+
385
+ # Admin login uses master realm
386
+ admin_login_url = self.login_url.replace(
387
+ f"/realms/{self.realm}/",
388
+ "/realms/master/"
389
+ )
390
+
391
+ admin_data = {
392
+ "username": admin_user,
393
+ "password": admin_password,
394
+ "grant_type": "password",
395
+ "client_id": "admin-cli" # Keycloak admin CLI client
396
+ }
397
+
398
+ response = requests.post(
399
+ admin_login_url,
400
+ data=admin_data,
401
+ timeout=self.request_timeout,
402
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
403
+ )
404
+
405
+ response.raise_for_status()
406
+ response_data = response.json()
407
+
408
+ admin_token = response_data["access_token"]
409
+ self.token_store.admin_token = admin_token
410
+
411
+ self.logger.info("Admin authentication successful")
412
+ return admin_token
413
+
414
+ except requests.exceptions.RequestException as error:
415
+ self.logger.error(f"Admin authentication failed: {error}")
416
+ raise BlackAntAuthenticationError(f"Admin login failed: {error}") from error
417
+ except (KeyError, ValueError) as error:
418
+ self.logger.error(f"Invalid admin login response: {error}")
419
+ raise BlackAntAuthenticationError(f"Invalid admin response: {error}") from error
420
+
421
+ def create_user(self, user_data: Dict[str, Any], admin_token: Optional[str] = None) -> str:
422
+ """Create new user using Keycloak Admin API.
423
+
424
+ Args:
425
+ user_data: User creation data (username, email, etc.).
426
+ admin_token: Admin token, uses stored if None.
427
+
428
+ Returns:
429
+ Created user ID.
430
+
431
+ Raises:
432
+ BlackAntAuthenticationError: If user creation fails.
433
+ """
434
+ try:
435
+ token = admin_token or self.token_store.admin_token
436
+ if not token:
437
+ raise BlackAntAuthenticationError("Admin token required for user creation")
438
+
439
+ admin_api_url = self.login_url.replace(
440
+ "/protocol/openid-connect/token",
441
+ f"/admin/realms/{self.realm}/users"
442
+ )
443
+
444
+ response = requests.post(
445
+ admin_api_url,
446
+ json=user_data,
447
+ headers={
448
+ "Authorization": f"Bearer {token}",
449
+ "Content-Type": "application/json"
450
+ },
451
+ timeout=self.request_timeout
452
+ )
453
+
454
+ response.raise_for_status()
455
+
456
+ # User ID is in Location header
457
+ location = response.headers.get("Location", "")
458
+ user_id = location.split("/")[-1] if location else ""
459
+
460
+ self.logger.info(f"User created: {user_data.get('username', 'unknown')}")
461
+ return user_id
462
+
463
+ except requests.exceptions.RequestException as error:
464
+ self.logger.error("User creation failed: %s", error)
465
+ raise BlackAntAuthenticationError(f"User creation failed: {error}") from error
466
+
467
+ def get_users(self, admin_token: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
468
+ """List users using Keycloak Admin API.
469
+
470
+ Args:
471
+ admin_token: Admin token, uses stored if None.
472
+ search: Optional search filter.
473
+
474
+ Returns:
475
+ List of user data.
476
+
477
+ Raises:
478
+ BlackAntAuthenticationError: If user listing fails.
479
+ """
480
+ try:
481
+ token = admin_token or self.token_store.admin_token
482
+ if not token:
483
+ raise BlackAntAuthenticationError("Admin token required for user listing")
484
+
485
+ admin_api_url = self.login_url.replace(
486
+ "/protocol/openid-connect/token",
487
+ f"/admin/realms/{self.realm}/users"
488
+ )
489
+
490
+ params = {}
491
+ if search:
492
+ params["search"] = search
493
+
494
+ response = requests.get(
495
+ admin_api_url,
496
+ params=params,
497
+ headers={"Authorization": f"Bearer {token}"},
498
+ timeout=self.request_timeout
499
+ )
500
+
501
+ response.raise_for_status()
502
+ users = response.json()
503
+
504
+ self.logger.debug(f"Retrieved {len(users)} users")
505
+ return users
506
+
507
+ except requests.exceptions.RequestException as error:
508
+ self.logger.error(f"User listing failed: {error}")
509
+ raise BlackAntAuthenticationError(f"User listing failed: {error}") from error
510
+
511
+ def logout(self):
512
+ """Clear stored authentication token.
513
+
514
+ Removes the stored Bearer token from token store.
515
+ """
516
+ self.logger.info("Logging out user")
517
+ self.token_store.user_token = None
518
+ self._authenticated = False