ibm-watsonx-orchestrate 1.12.0b0__py3-none-any.whl → 1.13.0b0__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 (66) hide show
  1. ibm_watsonx_orchestrate/__init__.py +2 -1
  2. ibm_watsonx_orchestrate/agent_builder/agents/types.py +5 -5
  3. ibm_watsonx_orchestrate/agent_builder/connections/types.py +34 -3
  4. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/types.py +11 -2
  5. ibm_watsonx_orchestrate/agent_builder/models/types.py +18 -1
  6. ibm_watsonx_orchestrate/agent_builder/toolkits/base_toolkit.py +1 -1
  7. ibm_watsonx_orchestrate/agent_builder/toolkits/types.py +14 -2
  8. ibm_watsonx_orchestrate/agent_builder/tools/__init__.py +1 -1
  9. ibm_watsonx_orchestrate/agent_builder/tools/base_tool.py +1 -1
  10. ibm_watsonx_orchestrate/agent_builder/tools/langflow_tool.py +61 -1
  11. ibm_watsonx_orchestrate/agent_builder/tools/openapi_tool.py +6 -0
  12. ibm_watsonx_orchestrate/agent_builder/tools/types.py +21 -3
  13. ibm_watsonx_orchestrate/agent_builder/voice_configurations/__init__.py +1 -1
  14. ibm_watsonx_orchestrate/agent_builder/voice_configurations/types.py +11 -0
  15. ibm_watsonx_orchestrate/cli/commands/agents/agents_controller.py +29 -53
  16. ibm_watsonx_orchestrate/cli/commands/connections/connections_command.py +2 -2
  17. ibm_watsonx_orchestrate/cli/commands/connections/connections_controller.py +56 -30
  18. ibm_watsonx_orchestrate/cli/commands/copilot/copilot_command.py +25 -2
  19. ibm_watsonx_orchestrate/cli/commands/copilot/copilot_controller.py +249 -14
  20. ibm_watsonx_orchestrate/cli/commands/copilot/copilot_server_controller.py +4 -4
  21. ibm_watsonx_orchestrate/cli/commands/environment/environment_command.py +5 -1
  22. ibm_watsonx_orchestrate/cli/commands/environment/environment_controller.py +6 -3
  23. ibm_watsonx_orchestrate/cli/commands/evaluations/evaluations_command.py +3 -2
  24. ibm_watsonx_orchestrate/cli/commands/evaluations/evaluations_controller.py +1 -1
  25. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_controller.py +45 -16
  26. ibm_watsonx_orchestrate/cli/commands/models/model_provider_mapper.py +23 -4
  27. ibm_watsonx_orchestrate/cli/commands/models/models_command.py +2 -2
  28. ibm_watsonx_orchestrate/cli/commands/models/models_controller.py +29 -10
  29. ibm_watsonx_orchestrate/cli/commands/partners/offering/partners_offering_controller.py +21 -4
  30. ibm_watsonx_orchestrate/cli/commands/partners/offering/types.py +7 -15
  31. ibm_watsonx_orchestrate/cli/commands/partners/partners_command.py +1 -1
  32. ibm_watsonx_orchestrate/cli/commands/server/server_command.py +30 -20
  33. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_command.py +2 -2
  34. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_controller.py +139 -27
  35. ibm_watsonx_orchestrate/cli/commands/tools/tools_command.py +2 -2
  36. ibm_watsonx_orchestrate/cli/commands/tools/tools_controller.py +79 -36
  37. ibm_watsonx_orchestrate/cli/commands/voice_configurations/voice_configurations_controller.py +23 -11
  38. ibm_watsonx_orchestrate/cli/common.py +26 -0
  39. ibm_watsonx_orchestrate/cli/config.py +33 -2
  40. ibm_watsonx_orchestrate/client/connections/connections_client.py +1 -14
  41. ibm_watsonx_orchestrate/client/copilot/cpe/copilot_cpe_client.py +34 -1
  42. ibm_watsonx_orchestrate/client/knowledge_bases/knowledge_base_client.py +6 -2
  43. ibm_watsonx_orchestrate/client/model_policies/model_policies_client.py +1 -1
  44. ibm_watsonx_orchestrate/client/models/models_client.py +1 -1
  45. ibm_watsonx_orchestrate/client/threads/threads_client.py +34 -0
  46. ibm_watsonx_orchestrate/client/utils.py +29 -7
  47. ibm_watsonx_orchestrate/docker/compose-lite.yml +58 -8
  48. ibm_watsonx_orchestrate/docker/default.env +26 -17
  49. ibm_watsonx_orchestrate/flow_builder/flows/decorators.py +10 -2
  50. ibm_watsonx_orchestrate/flow_builder/flows/flow.py +90 -16
  51. ibm_watsonx_orchestrate/flow_builder/node.py +14 -2
  52. ibm_watsonx_orchestrate/flow_builder/types.py +57 -3
  53. ibm_watsonx_orchestrate/langflow/__init__.py +0 -0
  54. ibm_watsonx_orchestrate/langflow/langflow_utils.py +195 -0
  55. ibm_watsonx_orchestrate/langflow/lfx_deps.py +84 -0
  56. ibm_watsonx_orchestrate/utils/async_helpers.py +31 -0
  57. ibm_watsonx_orchestrate/utils/docker_utils.py +1177 -33
  58. ibm_watsonx_orchestrate/utils/environment.py +165 -20
  59. ibm_watsonx_orchestrate/utils/exceptions.py +1 -1
  60. ibm_watsonx_orchestrate/utils/tokens.py +51 -0
  61. ibm_watsonx_orchestrate/utils/utils.py +63 -4
  62. {ibm_watsonx_orchestrate-1.12.0b0.dist-info → ibm_watsonx_orchestrate-1.13.0b0.dist-info}/METADATA +2 -2
  63. {ibm_watsonx_orchestrate-1.12.0b0.dist-info → ibm_watsonx_orchestrate-1.13.0b0.dist-info}/RECORD +66 -59
  64. {ibm_watsonx_orchestrate-1.12.0b0.dist-info → ibm_watsonx_orchestrate-1.13.0b0.dist-info}/WHEEL +0 -0
  65. {ibm_watsonx_orchestrate-1.12.0b0.dist-info → ibm_watsonx_orchestrate-1.13.0b0.dist-info}/entry_points.txt +0 -0
  66. {ibm_watsonx_orchestrate-1.12.0b0.dist-info → ibm_watsonx_orchestrate-1.13.0b0.dist-info}/licenses/LICENSE +0 -0
@@ -1,21 +1,46 @@
1
+ import hashlib
2
+ import json
1
3
  import logging
2
4
  import os
3
5
  import subprocess
4
6
  import sys
7
+ import tarfile
8
+ import tempfile
9
+ import threading
10
+ from abc import abstractmethod
11
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
12
+ from copy import deepcopy
5
13
  from enum import Enum
6
14
  from pathlib import Path
7
- from typing import MutableMapping
15
+ from typing import MutableMapping, Tuple
8
16
  from urllib.parse import urlparse
9
17
 
10
18
  import requests
11
19
  import typer
20
+ from rich.progress import (
21
+ BarColumn, Progress, TextColumn, TimeRemainingColumn, TaskProgressColumn, DownloadColumn
22
+ )
12
23
 
13
- from ibm_watsonx_orchestrate.cli.config import Config
14
- from ibm_watsonx_orchestrate.utils.environment import EnvService
24
+ from ibm_watsonx_orchestrate.cli.commands.environment.types import EnvironmentAuthType
25
+ from ibm_watsonx_orchestrate.client.utils import get_architecture, concat_bin_files, is_arm_architecture, \
26
+ get_arm_architectures
27
+ from ibm_watsonx_orchestrate.utils.environment import EnvSettingsService, EnvService, DeveloperEditionSources
28
+ from ibm_watsonx_orchestrate.utils.tokens import CpdWxOTokenService
29
+ from ibm_watsonx_orchestrate.utils.utils import yaml_safe_load
15
30
 
16
31
  logger = logging.getLogger(__name__)
17
32
 
18
33
 
34
+ class DockerOCIContainerMediaTypes(str, Enum):
35
+ LIST_V1 = "application/vnd.oci.image.index.v1+json"
36
+ LIST_V2 = "application/vnd.docker.distribution.manifest.list.v2+json"
37
+ V1 = "application/vnd.oci.image.manifest.v1+json"
38
+ V2 = "application/vnd.docker.distribution.manifest.v2+json"
39
+
40
+ def __str__(self) -> str:
41
+ return str(self.value)
42
+
43
+
19
44
  class DockerUtils:
20
45
 
21
46
  @staticmethod
@@ -27,7 +52,22 @@ class DockerUtils:
27
52
  sys.exit(1)
28
53
 
29
54
  @staticmethod
30
- def check_exclusive_observability(langfuse_enabled: bool, ibm_tele_enabled: bool):
55
+ def image_exists_locally (image: str, tag: str) -> bool:
56
+ DockerUtils.ensure_docker_installed()
57
+
58
+ result = subprocess.run([
59
+ "docker",
60
+ "images",
61
+ "--format",
62
+ "\"{{.Repository}}:{{.Tag}}\"",
63
+ "--filter",
64
+ f"reference={image}"
65
+ ], env=os.environ, capture_output=True)
66
+
67
+ return f"{image}:{tag}" in str(result.stdout)
68
+
69
+ @staticmethod
70
+ def check_exclusive_observability (langfuse_enabled: bool, ibm_tele_enabled: bool):
31
71
  if langfuse_enabled and ibm_tele_enabled:
32
72
  return False
33
73
  if langfuse_enabled and DockerUtils.__is_docker_container_running("docker-frontend-server-1"):
@@ -37,7 +77,40 @@ class DockerUtils:
37
77
  return True
38
78
 
39
79
  @staticmethod
40
- def __is_docker_container_running(container_name):
80
+ def import_image (tar_file_path: Path):
81
+ DockerUtils.ensure_docker_installed()
82
+
83
+ if tar_file_path is None:
84
+ raise ValueError("No image path provided. Cannot import docker image.")
85
+
86
+ if not tar_file_path.exists() or not tar_file_path.is_file():
87
+ raise ValueError(f"Provided path of tar file does not exist or could not be accessed. Cannot import docker image @ \"{tar_file_path}\".")
88
+
89
+ try:
90
+ result = subprocess.run([
91
+ "docker",
92
+ "load",
93
+ "-i",
94
+ f"{tar_file_path.absolute()}"
95
+ ], check=True, capture_output=True)
96
+
97
+ if result.returncode != 0:
98
+ logger.error(f"Failed to import image tar: {result.stderr}")
99
+ sys.exit(1)
100
+
101
+ except subprocess.CalledProcessError as ex:
102
+ logger.error(f"Failed to import docker image. Return Code: {ex.returncode}")
103
+
104
+ if ex.output:
105
+ logger.debug(f"Command output: {ex.output.decode()}")
106
+
107
+ if ex.stderr:
108
+ logger.debug(f"Command error output: {ex.stderr.decode()}")
109
+
110
+ sys.exit(1)
111
+
112
+ @staticmethod
113
+ def __is_docker_container_running (container_name):
41
114
  DockerUtils.ensure_docker_installed()
42
115
  command = ["docker",
43
116
  "ps",
@@ -50,9 +123,963 @@ class DockerUtils:
50
123
  return False
51
124
 
52
125
 
126
+ class CpdDockerPullProgressNotifier:
127
+
128
+ @abstractmethod
129
+ def initialize (self):
130
+ raise NotImplementedError()
131
+
132
+ @abstractmethod
133
+ def progress (self, chunk_size: int):
134
+ raise NotImplementedError()
135
+
136
+ @abstractmethod
137
+ def completed (self):
138
+ raise NotImplementedError()
139
+
140
+ @abstractmethod
141
+ def failed(self):
142
+ raise NotImplementedError()
143
+
144
+ @abstractmethod
145
+ def is_initialized (self) -> bool:
146
+ raise NotImplementedError()
147
+
148
+
149
+ class CpdRichProgress(Progress):
150
+
151
+ def get_default_columns(self):
152
+ return (
153
+ TextColumn("[#32afff][progress.description]{task.description}"),
154
+ TaskProgressColumn(text_format="[progress.percentage]{task.percentage:>3.0f}%"),
155
+ BarColumn(complete_style="#32afff", finished_style="#366d1b"),
156
+ DownloadColumn(binary_units=False),
157
+ TimeRemainingColumn(compact=True, elapsed_when_finished=True),
158
+ TextColumn("[red]{task.fields[status]}"),
159
+ )
160
+
161
+ @staticmethod
162
+ def get_instance ():
163
+ return CpdRichProgress(
164
+ transient=False,
165
+ auto_refresh=True,
166
+ refresh_per_second=5,
167
+ )
168
+
169
+
170
+ class SimpleRichCpdDockerPullProgressNotifier(CpdDockerPullProgressNotifier):
171
+
172
+ def __init__ (self, layer_descriptor: str, layer_bytes: int, progress: CpdRichProgress):
173
+ self.__progress = progress
174
+ self.__layer_descriptor = layer_descriptor
175
+ self.__layer_bytes = layer_bytes
176
+
177
+ self.__lock = threading.Lock()
178
+ self.__task = None
179
+
180
+ def initialize (self):
181
+ with self.__lock:
182
+ self.__task = self.__progress.add_task(self.__layer_descriptor, total=self.__layer_bytes, visible=True, status="")
183
+ self.__progress.update(self.__task, completed=0)
184
+ # self.__progress.refresh()
185
+
186
+ def progress (self, chunk_size: int):
187
+ with self.__lock:
188
+ self.__progress.update(self.__task, advance=chunk_size)
189
+ # self.__progress.refresh()
190
+
191
+ def completed (self):
192
+ with self.__lock:
193
+ if not self.__progress.tasks[self.__task].finished:
194
+ self.__progress.update(self.__task, completed=self.__layer_bytes, visible=True)
195
+ # self.__progress.refresh()
196
+
197
+ def failed (self):
198
+ with self.__lock:
199
+ if not self.__progress.tasks[self.__task].finished:
200
+ self.__progress.update(self.__task, status="[ Failed ]")
201
+
202
+ def is_initialized (self) -> bool:
203
+ with self.__lock:
204
+ return self.__task is not None
205
+
206
+
207
+ class CpdDockerRequestsService:
208
+
209
+ def __init__ (self, env_settings: EnvSettingsService, cpd_wxo_token_service: CpdWxOTokenService) -> None:
210
+ self.__cpd_wxo_token_service = cpd_wxo_token_service
211
+ url_scheme, hostname, orchestrate_namespace, wxo_tenant_id = env_settings.get_parsed_wo_instance_details()
212
+ self.__wxo_tenant_id = wxo_tenant_id
213
+
214
+ self.__ssl_verify = env_settings.get_wo_instance_ssl_verify()
215
+
216
+ self.__cpd_docker_url = f"{url_scheme}://{hostname}/orchestrate/{orchestrate_namespace}/docker"
217
+ # self.__cpd_docker_url = f"{url_scheme}://{hostname}""
218
+
219
+ def is_docker_proxy_up(self):
220
+ url = f"{self.__cpd_docker_url}/health"
221
+ headers = {
222
+ **self.__get_base_request_headers(),
223
+ "Accept": "application/json",
224
+ }
225
+
226
+ response = None
227
+
228
+ try:
229
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
230
+
231
+ if response.status_code != 200:
232
+ logger.error(f"Received non-200 response from upstream CPD Docker service. Received: {response.status_code}{self.__get_log_request_id(response)}")
233
+ return False
234
+
235
+ if response.json()["status"] != "OK":
236
+ logger.error(f"Upstream CPD Docker service responded with non-OK status. Received: {response.json()['status']}{self.__get_log_request_id(response)}")
237
+ return False
238
+
239
+ except (json.decoder.JSONDecodeError, KeyError):
240
+ logger.error(f"Received unrecognized or unparsable response from upstream CPD Docker service{self.__get_log_request_id(response)}")
241
+ return False
242
+
243
+ except Exception as ex:
244
+ logger.error(ex)
245
+ logger.error("Failed to reach upstream CPD Docker service.")
246
+ return False
247
+
248
+ return True
249
+
250
+ def get_manifests(self, image: str, tag: str,
251
+ manifest_media_type: str = DockerOCIContainerMediaTypes.LIST_V2.value) -> list:
252
+ url = f"{self.__cpd_docker_url}/v2/{image}/manifests/{tag}"
253
+ headers = {
254
+ **self.__get_base_request_headers(image),
255
+ "Accept": manifest_media_type,
256
+ }
257
+
258
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
259
+
260
+ if response.status_code == 404:
261
+ if manifest_media_type == DockerOCIContainerMediaTypes.LIST_V2.value:
262
+ return self.get_manifests(image=image, tag=tag, manifest_media_type=DockerOCIContainerMediaTypes.LIST_V1.value)
263
+
264
+ else:
265
+ logger.error(f"Could not find OCI manifest list for image {image}:{tag}")
266
+ logger.error("Image may not exist in docker registry.")
267
+ sys.exit(1)
268
+
269
+ if response.status_code != 200:
270
+ raise Exception(f"Received unexpected, non-200 response while trying to retrieve manifests for image {image}:{tag}. Received: {response.status_code}{self.__get_log_request_id(response)}")
271
+
272
+ try:
273
+ resp_json = response.json()
274
+
275
+ except json.decoder.JSONDecodeError:
276
+ raise Exception(f"Failed to parse JSON from cloud registry response while trying to fetch manifests for image {image}:{tag}.{self.__get_log_request_id(response)}")
277
+
278
+ if "manifests" not in resp_json:
279
+ raise Exception(
280
+ f"Received unrecognized response from cloud registry while retrieving manifests for image {image}:{tag}.{self.__get_log_request_id(response)}")
281
+
282
+ elif len(resp_json["manifests"]) < 1:
283
+ raise Exception(f"Retrieved manifests list is empty for image {image}:{tag}.")
284
+
285
+ return resp_json["manifests"]
286
+
287
+ def get_manifest_for_digest (self, image: str, digest: str, media_type: str):
288
+ url = f"{self.__cpd_docker_url}/v2/{image}/manifests/{digest}"
289
+ headers = {
290
+ **self.__get_base_request_headers(image),
291
+ "Accept": media_type,
292
+ }
293
+
294
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
295
+
296
+ if response.status_code == 404:
297
+ raise Exception(f"Could not find manifest for image {image}@{digest}.")
298
+
299
+ if response.status_code != 200:
300
+ raise Exception(f"Received unexpected, non-200 response while trying to retrieve manifest for image {image}@{digest}. Received: {response.status_code}{self.__get_log_request_id(response)}")
301
+
302
+ try:
303
+ resp_json = response.json()
304
+
305
+ except json.decoder.JSONDecodeError:
306
+ raise Exception(f"Failed to parse JSON from cloud registry response while trying to fetch manifest for image {image}@{digest}.{self.__get_log_request_id(response)}")
307
+
308
+ return resp_json
309
+
310
+ def get_manifest (self, image: str, tag: str, media_type: str):
311
+ url = f"{self.__cpd_docker_url}/v2/{image}/manifests/{tag}"
312
+ headers = {
313
+ **self.__get_base_request_headers(image),
314
+ "Accept": media_type,
315
+ }
316
+
317
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
318
+
319
+ if response.status_code == 404:
320
+ raise Exception(f"Could not find manifest for image {image}:{tag}.")
321
+
322
+ if response.status_code != 200:
323
+ raise Exception(f"Received unexpected, non-200 response while trying to retrieve manifest for image {image}:{tag}. Received: {response.status_code}{self.__get_log_request_id(response)}")
324
+
325
+ try:
326
+ resp_json = response.json()
327
+
328
+ except json.decoder.JSONDecodeError:
329
+ raise Exception(f"Failed to parse JSON from cloud registry response while trying to fetch manifest for image {image}:{tag}.{self.__get_log_request_id(response)}")
330
+
331
+ return {
332
+ "manifest" : resp_json,
333
+ "digest" : response.headers.get("docker-content-digest")
334
+ }
335
+
336
+ def get_head_digest (self, image: str, tag: str, media_type: str) -> str | None:
337
+ url = f"{self.__cpd_docker_url}/v2/{image}/manifests/{tag}"
338
+ headers = {
339
+ **self.__get_base_request_headers(image),
340
+ "Accept": media_type,
341
+ }
342
+
343
+ response = requests.head(url, headers=headers, verify=self.__ssl_verify)
344
+
345
+ if response.status_code >= 500:
346
+ raise Exception(f"Failed to retrieve HEAD digest for image {image}:{tag}. Received: {response.status_code}{self.__get_log_request_id(response)}")
347
+
348
+ elif response.status_code == 200:
349
+ return response.headers.get("docker-content-digest")
350
+
351
+ else:
352
+ return None
353
+
354
+ def get_manifest_list_or_manifest(self, image: str, tag: str) -> Tuple[list[dict] | None, dict | None, str | None]:
355
+ url = f"{self.__cpd_docker_url}/v2/{image}/manifests/{tag}"
356
+ headers = {
357
+ **self.__get_base_request_headers(image),
358
+ "Accept": ", ".join([DockerOCIContainerMediaTypes.LIST_V2.value, DockerOCIContainerMediaTypes.LIST_V1.value,
359
+ DockerOCIContainerMediaTypes.V2.value]),
360
+ }
361
+
362
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
363
+
364
+ if response.status_code == 404:
365
+ logger.error(f"Could not find OCI manifest(s) for image {image}:{tag}. Received: {response.status_code}{self.__get_log_request_id(response)}")
366
+ logger.error("Image may not exist in docker registry.")
367
+ sys.exit(1)
368
+
369
+ elif response.status_code != 200:
370
+ raise Exception(f"Failed to retrieve manifest list or manifest digest for image {image}:{tag}. Received: {response.status_code}{self.__get_log_request_id(response)}")
371
+
372
+ content_type = response.headers.get("content-type")
373
+ content_type = content_type.strip() if content_type is not None else None
374
+ is_manifest_list = False
375
+ digest = None
376
+
377
+ if content_type is None or content_type == "":
378
+ logger.error(f"Received a response from cloud registry which does not include content type response header{self.__get_log_request_id(response)}")
379
+ sys.exist(1)
380
+
381
+ elif content_type in (DockerOCIContainerMediaTypes.LIST_V2.value, DockerOCIContainerMediaTypes.LIST_V1.value):
382
+ is_manifest_list = True
383
+
384
+ elif content_type != DockerOCIContainerMediaTypes.V2.value:
385
+ logger.error(f"Received a response from cloud registry with an unexpected content type: {content_type}{self.__get_log_request_id(response)}")
386
+ sys.exit(1)
387
+
388
+ else:
389
+ digest = response.headers.get("docker-content-digest")
390
+ digest = digest.strip() if digest is not None else None
391
+
392
+ if digest is None or digest == "":
393
+ raise Exception(f"Received an unexpected manfiest response from cloud registry which is missing docker content digest header{self.__get_log_request_id(response)}")
394
+
395
+ try:
396
+ resp_json = response.json()
397
+
398
+ except json.decoder.JSONDecodeError:
399
+ raise Exception(f"Failed to parse JSON from cloud registry response while trying to fetch manifest(s) for image {image}:{tag}.{self.__get_log_request_id(response)}")
400
+
401
+ if is_manifest_list:
402
+ if "manifests" not in resp_json:
403
+ raise Exception(f"Received unrecognized manifest list response from cloud registry for image {image}:{tag}.{self.__get_log_request_id(response)}")
404
+
405
+ elif len(resp_json["manifests"]) < 1:
406
+ raise Exception(f"Retrieved manifests list is empty for image {image}:{tag}.")
407
+
408
+ return resp_json["manifests"], None, digest
409
+
410
+ else:
411
+ return None, resp_json, digest
412
+
413
+ def get_config_blob (self, image: str, config_digest: str, media_type: str):
414
+ url = f"{self.__cpd_docker_url}/v2/{image}/blobs/{config_digest}"
415
+ headers = {
416
+ **self.__get_base_request_headers(image),
417
+ "Accept": media_type,
418
+ }
419
+
420
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify)
421
+
422
+ if response.status_code != 200:
423
+ raise Exception(f"Received unexpected, non-200 response while trying to retrieve config blob (digest: {config_digest}) for image {image}. Received: {response.status_code}{self.__get_log_request_id(response)}")
424
+
425
+ return response.content
426
+
427
+ def get_streaming_blob_response (self, image: str, blob_digest: str, media_type: str, layer: dict, byte_range: dict = None):
428
+ url = f"{self.__cpd_docker_url}/v2/{image}/blobs/{blob_digest}"
429
+ headers = {
430
+ **self.__get_base_request_headers(image),
431
+ "Accept": media_type,
432
+ }
433
+
434
+ if byte_range is not None and "start" in byte_range and "end" in byte_range:
435
+ headers["Range"] = f"bytes={byte_range['start']}-{byte_range['end']}"
436
+
437
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify, stream=True)
438
+
439
+ if response.status_code not in (200, 206):
440
+ if "urls" in layer:
441
+ return self.__get_streaming_layer_blob_url_response(url=layer["urls"][0], image=image, blob_digest=blob_digest, media_type=media_type)
442
+
443
+ else:
444
+ raise Exception(f"Failed to download layer {blob_digest}. Received unexpected, non-200 response for image {image}. Received: {response.status_code}{self.__get_log_request_id(response)}")
445
+
446
+ return response
447
+
448
+ def __get_streaming_layer_blob_url_response(self, url: str, image: str, blob_digest: str, media_type: str):
449
+ headers = {
450
+ **self.__get_base_request_headers(image),
451
+ "Accept": media_type,
452
+ }
453
+
454
+ response = requests.get(url, headers=headers, verify=self.__ssl_verify, stream=True)
455
+
456
+ if response.status_code != 200:
457
+ raise Exception(f"Failed to download layer {blob_digest}. Received unexpected, non-200 response for image {image}. Received: {response.status_code}, Custom URL: \"{url}\"{self.__get_log_request_id(response)}")
458
+
459
+ return response
460
+
461
+ def __get_base_request_headers (self, image: str = None):
462
+ return {
463
+ "Authorization" : f"Bearer {self.__cpd_wxo_token_service.get_token()}",
464
+ "X-Tenant-ID" : self.__wxo_tenant_id,
465
+ **({ "scope" : f"repository:{image}:pull" } if image else {}),
466
+ **({ "Accept-Encoding" : "gzip" } if image else {}),
467
+ }
468
+
469
+ @staticmethod
470
+ def __get_log_request_id (response, ignore_comma_prefix: bool = False) -> str:
471
+ id = "<unknown>"
472
+
473
+ if response is not None and response.headers.get("X-Service-Request-ID"):
474
+ id = response.headers.get("X-Service-Request-ID")
475
+
476
+ result = f"{'' if ignore_comma_prefix else ', '}Request ID: {id}"
477
+
478
+ if response is not None and response.headers.get("IBM-CPD-Transaction-ID"):
479
+ result += f", CPD Transaction ID: {response.headers.get('IBM-CPD-Transaction-ID')}"
480
+
481
+ return result
482
+
483
+
484
+ class BaseCpdDockerImagePullService:
485
+
486
+ @abstractmethod
487
+ def pull(self, image: str, tag: str, manifest: dict, local_image_name: str) -> None:
488
+ raise NotImplementedError()
489
+
490
+
491
+ class CpdDockerV1ImagePullService(BaseCpdDockerImagePullService):
492
+
493
+ def __init__ (self, docker_requests_service: CpdDockerRequestsService) -> None:
494
+ self.__docker_requests_service = docker_requests_service
495
+
496
+ def pull(self, image: str, tag: str, manifest: dict, local_image_name: str) -> None:
497
+ raise NotImplementedError()
498
+
499
+
500
+ class CpdDockerV2ImagePullService(BaseCpdDockerImagePullService):
501
+
502
+ __1_mb = 1024 * 1024
503
+ __10_mb = 10 * __1_mb
504
+ __20_mb = 20 * __1_mb
505
+ __50_mb = 50 * __1_mb
506
+ __100_mb = 100 * __1_mb
507
+ __150_mb = 150 * __1_mb
508
+ __200_mb = 2 * __100_mb
509
+ __500_mb = 500 * __1_mb
510
+ __1_gb = 1024 * __1_mb
511
+
512
+ def __init__ (self, env_settings: EnvSettingsService, docker_requests_service: CpdDockerRequestsService) -> None:
513
+ self.__env_settings = env_settings
514
+ self.__docker_requests_service = docker_requests_service
515
+
516
+ def pull(self, image: str, tag: str, manifest: dict, local_image_name: str) -> None:
517
+ final_image_tar = None
518
+
519
+ try:
520
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, prefix="rendered-image-") as ntf:
521
+ final_image_tar = ntf.name
522
+
523
+ with tempfile.TemporaryDirectory() as image_structure_dir:
524
+ with ThreadPoolExecutor(max_workers=self.__env_settings.get_docker_pull_parallel_worker_count()) as executor:
525
+ self.__pull_and_construct_image_archive(executor=executor, final_image_tar=final_image_tar,
526
+ image=image, image_structure_dir=image_structure_dir,
527
+ manifest=manifest, tag=tag,
528
+ local_image_name=local_image_name)
529
+
530
+ logger.debug("Importing docker image")
531
+ DockerUtils.import_image(Path(final_image_tar))
532
+
533
+ finally:
534
+ if final_image_tar is not None:
535
+ os.unlink(final_image_tar)
536
+
537
+ def __pull_and_construct_image_archive(self, executor: ThreadPoolExecutor, final_image_tar: str, image: str,
538
+ image_structure_dir: str, manifest: dict, tag: str, local_image_name: str) -> None:
539
+ media_type = manifest['mediaType']
540
+ config_digest = manifest["config"]["digest"]
541
+ config_blob_file_path = os.path.join(image_structure_dir, f"{config_digest[7:]}.json")
542
+
543
+ with open(config_blob_file_path, 'wb') as file:
544
+ file.write(self.__docker_requests_service.get_config_blob(image, config_digest, media_type))
545
+
546
+ content = [{
547
+ "Config": f"{config_digest[7:]}.json",
548
+ "RepoTags": [
549
+ f"{local_image_name}:{tag}"
550
+ ],
551
+ "Layers": []
552
+ }]
553
+
554
+ with CpdRichProgress.get_instance() as progress_instance:
555
+ computed_layer_id = self.__retrieve_layers(config_blob_file_path=config_blob_file_path, content=content,
556
+ executor=executor, image=image,
557
+ image_structure_dir=image_structure_dir, manifest=manifest,
558
+ media_type=media_type, progress_instance=progress_instance)
559
+
560
+ logger.debug("Retrieved layers")
561
+
562
+ with open(os.path.join(image_structure_dir, 'manifest.json'), 'w') as file:
563
+ file.write(json.dumps(content))
564
+
565
+ with open(os.path.join(image_structure_dir, "repositories"), "w") as repo_file:
566
+ repo_file.write(json.dumps({
567
+ f"{local_image_name}": {
568
+ "tag": computed_layer_id,
569
+ }
570
+ }))
571
+
572
+ logger.debug("Compressing archive")
573
+ with tarfile.open(final_image_tar, mode="w") as tar:
574
+ tar.add(image_structure_dir, arcname=os.path.sep)
575
+
576
+ def __retrieve_layers(self, config_blob_file_path: str, content: list, executor: ThreadPoolExecutor, image: str,
577
+ image_structure_dir: str, manifest: dict, media_type: str,
578
+ progress_instance: CpdRichProgress) -> str:
579
+ pull_subtasks = []
580
+ parent_id = ""
581
+ last_layer_digest = manifest["layers"][-1]['digest']
582
+ layer_count = len(manifest["layers"])
583
+ computed_layer_id = None
584
+
585
+ if len(manifest["layers"]) < 1:
586
+ # this should never happen but is here as a sanity check.
587
+ raise Exception("Encountered a docker manifest without any layers")
588
+
589
+ for layer_index, layer in enumerate(manifest["layers"]):
590
+ layer_track = f"{str(layer_index + 1).zfill(len(str(layer_count)))} / {layer_count}"
591
+ layer_digest = layer["digest"]
592
+ layer_descriptor = f"{layer['digest'][7:20]} [ {layer_track} ]"
593
+
594
+ if "size" not in layer:
595
+ # the size should typically be there. this is just a sanity check.
596
+ raise Exception(f"Encountered a docker manifest layer which is missing \"size\". Layer digest: {layer_digest}")
597
+
598
+ layer_bytes = int(layer["size"])
599
+ computed_layer_id = hashlib.sha256(f"{parent_id}\n{layer_digest}\n".encode('utf-8')).hexdigest()
600
+
601
+ layer_dir_path = os.path.join(image_structure_dir, computed_layer_id)
602
+ os.mkdir(layer_dir_path)
603
+
604
+ # Creating VERSION file
605
+ with open(os.path.join(layer_dir_path, 'VERSION'), 'w') as file:
606
+ file.write('1.0')
607
+
608
+ layer_gzip_tar_file_path = os.path.join(layer_dir_path, "layer_gzip.tar")
609
+
610
+ if self.__env_settings.use_parallel_docker_image_layer_pulls() is True:
611
+ # progress_notifier, cancel_event and blob_chunk_size need to be set.
612
+ layer["layer_descriptor"] = layer_descriptor
613
+ pull_subtasks.append({
614
+ "image": image,
615
+ "layer": layer,
616
+ "layer_digest": layer_digest,
617
+ "media_type": media_type,
618
+ "layer_gzip_tar_file_path": layer_gzip_tar_file_path,
619
+ })
620
+
621
+ else:
622
+ progress_notifier = SimpleRichCpdDockerPullProgressNotifier(layer_descriptor=layer_descriptor,
623
+ layer_bytes=layer_bytes,
624
+ progress=progress_instance)
625
+
626
+ self.__retrieve_layer_core(image=image, layer=layer, layer_digest=layer_digest, media_type=media_type,
627
+ layer_gzip_tar_file_path=layer_gzip_tar_file_path, cancel_event=None,
628
+ progress_notifier=progress_notifier,
629
+ blob_chunk_size=self.__get_blob_chunk_size(layer_bytes))
630
+
631
+ # NOTE: we're explicitly using unix path separator here to account for windows. docker, in windows, runs in
632
+ # WSL which is linux and uses unix path separator while python code that executes in native windows
633
+ # environment on the same system (which means that it will use a windows path seapartor). this causes a
634
+ # clash that causes docker image tar import failures (which execute in WSL).
635
+ content[0]["Layers"].append("/".join(Path(layer_gzip_tar_file_path).relative_to(image_structure_dir).parts))
636
+
637
+ with open(os.path.join(layer_dir_path, 'json'), 'w') as json_file:
638
+ config_json = None
639
+ if last_layer_digest == layer_digest:
640
+ with open(config_blob_file_path, "r") as config_file:
641
+ config_json = json.load(config_file)
642
+
643
+ del config_json["history"]
644
+
645
+ for key in [x for x in config_json.keys() if x.lower() == "rootfs"]:
646
+ config_json.pop(key, None)
647
+
648
+ else:
649
+ config_json = self.__fallback_layer_config_json()
650
+
651
+ config_json["id"] = computed_layer_id
652
+
653
+ if parent_id:
654
+ config_json["parent"] = parent_id
655
+
656
+ json_file.write(json.dumps(config_json))
657
+ parent_id = config_json["id"]
658
+
659
+ self.__retrieve_layers_concurrently(parallel_layer_pull_tasks=pull_subtasks, executor=executor, progress_instance=progress_instance)
660
+
661
+ return computed_layer_id
662
+
663
+ def __get_ranged_pull_subtasks(self, pull_task: dict) -> list:
664
+ if self.__env_settings.use_ranged_requests_during_docker_pulls() is not True:
665
+ return []
666
+
667
+ layer_bytes = int(pull_task["layer"]["size"])
668
+
669
+ if layer_bytes < self.__200_mb:
670
+ return []
671
+
672
+ start_index = 0
673
+ end_index = layer_bytes - 1
674
+ range_end_index = 0
675
+ counter = 0
676
+
677
+ subtasks = []
678
+
679
+ while True:
680
+ range_end_index = start_index + self.__150_mb
681
+
682
+ if range_end_index > end_index:
683
+ range_end_index = end_index
684
+
685
+ ranged_subtask = deepcopy(pull_task)
686
+ ranged_subtask["byte_range"] = {
687
+ "start": start_index,
688
+ "end": range_end_index
689
+ }
690
+
691
+ ranged_subtask["layer"]["layer_gzip_tar_file_path"] = ranged_subtask["layer_gzip_tar_file_path"]
692
+ ranged_subtask["layer_gzip_tar_file_path"] = str(os.path.join(os.path.dirname(ranged_subtask["layer_gzip_tar_file_path"]), f"part-{counter}.bin"))
693
+ ranged_subtask["layer"]["size"] = range_end_index - start_index + 1
694
+
695
+ counter += 1
696
+
697
+ subtasks.append(ranged_subtask)
698
+
699
+ start_index = range_end_index + 1
700
+
701
+ if range_end_index >= end_index:
702
+ break
703
+
704
+ return subtasks
705
+
706
+ def __retrieve_layers_concurrently(self, parallel_layer_pull_tasks: list, executor: ThreadPoolExecutor,
707
+ progress_instance: CpdRichProgress) -> None:
708
+ if len(parallel_layer_pull_tasks) < 1:
709
+ return
710
+
711
+ ranged_subtasks = {}
712
+ all_subtasks = []
713
+ progress_notifiers = {}
714
+ for pull_task in parallel_layer_pull_tasks:
715
+ subtasks = self.__get_ranged_pull_subtasks(pull_task)
716
+
717
+ if len(subtasks) > 0:
718
+ pull_task["file_parts"] = [x["layer_gzip_tar_file_path"] for x in subtasks]
719
+ all_subtasks.extend(subtasks)
720
+ ranged_subtasks[pull_task["layer"]["layer_descriptor"]] = pull_task
721
+
722
+ else:
723
+ all_subtasks.append(pull_task)
724
+
725
+ cancel_event = threading.Event()
726
+ futures = []
727
+ has_failed = False
728
+
729
+ try:
730
+ for subtask in all_subtasks:
731
+ name = subtask["layer"]["layer_descriptor"]
732
+ subtask_layer_bytes = subtask["layer"]["size"]
733
+ progress_notifier = None
734
+
735
+ if name in progress_notifiers.keys():
736
+ progress_notifier = progress_notifiers[name]
737
+
738
+ else:
739
+ layer_bytes = ranged_subtasks[name]["layer"]["size"] \
740
+ if name in ranged_subtasks.keys() \
741
+ else subtask_layer_bytes
742
+
743
+ progress_notifier = SimpleRichCpdDockerPullProgressNotifier(layer_descriptor=name,
744
+ layer_bytes=layer_bytes,
745
+ progress=progress_instance)
746
+
747
+ progress_notifiers[name] = progress_notifier
748
+
749
+ blob_chunk_size = self.__get_blob_chunk_size(subtask_layer_bytes)
750
+ future = executor.submit(self.__retrieve_layer_core, **subtask, progress_notifier=progress_notifier,
751
+ cancel_event=cancel_event, blob_chunk_size=blob_chunk_size)
752
+
753
+ future.layer_name = name
754
+ futures.append(future)
755
+
756
+ done, not_done = wait(futures, return_when=FIRST_EXCEPTION)
757
+ failed_futures = [x for x in done if x.exception() is not None]
758
+ progress_instance.stop()
759
+ if len(failed_futures) > 0:
760
+ has_failed = True
761
+ executor.shutdown(wait=False, cancel_futures=True)
762
+
763
+ for pull_task in failed_futures:
764
+ logger.error(f"{pull_task.layer_name}: {pull_task.exception()}")
765
+
766
+ except Exception as ex:
767
+ has_failed = True
768
+ if len(futures) > 0:
769
+ cancel_event.set()
770
+ executor.shutdown(wait=False, cancel_futures=True)
771
+ progress_instance.stop()
772
+
773
+ logger.error(ex)
774
+ logger.error(f"Failed to spawn layer pulls. Cancelling operation.")
775
+
776
+ finally:
777
+ if has_failed:
778
+ sys.exit(1)
779
+
780
+ # extract any ranged pull file parts.
781
+ msg_logged = False
782
+ for layer_descriptor, ranged_subtask in ranged_subtasks.items():
783
+ if not msg_logged:
784
+ logger.debug("Extracting layers")
785
+ msg_logged = True
786
+
787
+ try:
788
+ concat_bin_files(target_bin_file=ranged_subtask["layer_gzip_tar_file_path"],
789
+ source_files=ranged_subtask["file_parts"], read_chunk_size=self.__200_mb,
790
+ delete_source_files_post=True)
791
+
792
+ except Exception as ex:
793
+ logger.error(f"Failed to extract layer: {layer_descriptor}")
794
+ raise ex
795
+
796
+ def __get_blob_chunk_size(self, layer_bytes: int):
797
+ if layer_bytes >= self.__1_gb:
798
+ return self.__100_mb
799
+
800
+ elif layer_bytes >= self.__500_mb:
801
+ return self.__50_mb
802
+
803
+ elif layer_bytes >= self.__200_mb:
804
+ return self.__20_mb
805
+
806
+ elif layer_bytes >= self.__100_mb:
807
+ return self.__10_mb
808
+
809
+ else:
810
+ return self.__1_mb
811
+
812
+ def __retrieve_layer_core(self, image: str, layer: dict, layer_digest: str, media_type: str,
813
+ layer_gzip_tar_file_path: str, blob_chunk_size: int, cancel_event: threading.Event | None,
814
+ progress_notifier: CpdDockerPullProgressNotifier, byte_range: dict = None) -> None:
815
+ try:
816
+ is_ranged_pull = byte_range is not None and "start" in byte_range and "end" in byte_range
817
+ if not is_ranged_pull or not progress_notifier.is_initialized():
818
+ # if this is a ranged request, the progress bar task might already have been initialized. hence, we only
819
+ # initialize when needed.
820
+ progress_notifier.initialize()
821
+
822
+ streaming_layer_resp = self.__docker_requests_service.get_streaming_blob_response(image=image,
823
+ blob_digest=layer_digest,
824
+ media_type=media_type,
825
+ layer=layer,
826
+ byte_range=byte_range)
827
+
828
+ streaming_layer_resp.raise_for_status()
829
+
830
+ with open(layer_gzip_tar_file_path, "wb") as file:
831
+ for chunk in streaming_layer_resp.iter_content(chunk_size=blob_chunk_size):
832
+ if cancel_event is not None and cancel_event.is_set():
833
+ return
834
+
835
+ if chunk:
836
+ file.write(chunk)
837
+ progress_notifier.progress(chunk_size=len(chunk))
838
+
839
+ if not is_ranged_pull:
840
+ # we do not want to set the notifier to completed when servicing ranged requests. ranged requests are
841
+ # executed in parallel and hence will complete when parallelly executing ranged requests complete.
842
+ progress_notifier.completed()
843
+
844
+ except Exception as ex:
845
+ if progress_notifier is not None:
846
+ progress_notifier.failed()
847
+
848
+ if cancel_event is not None:
849
+ cancel_event.set()
850
+
851
+ raise ex
852
+
853
+ @staticmethod
854
+ def __fallback_layer_config_json ():
855
+ return {
856
+ "created": "1970-01-01T00:00:00Z",
857
+ "container_config": {
858
+ "Hostname": "",
859
+ "Domainname": "",
860
+ "User": "",
861
+ "AttachStdin": False,
862
+ "AttachStdout": False,
863
+ "AttachStderr": False,
864
+ "Tty": False,
865
+ "OpenStdin": False,
866
+ "StdinOnce": False,
867
+ "Env": None,
868
+ "Cmd": None,
869
+ "Image": "",
870
+ "Volumes": None,
871
+ "WorkingDir": "",
872
+ "Entrypoint": None,
873
+ "OnBuild": None,
874
+ "Labels": None
875
+ }
876
+ }
877
+
878
+
879
+ class CpdDockerImagePullService:
880
+
881
+ def __init__(self, docker_requests_service: CpdDockerRequestsService,
882
+ cpd_v1_image_pull_service: CpdDockerV1ImagePullService,
883
+ cpd_v2_image_pull_service: CpdDockerV2ImagePullService,
884
+ env_settings: EnvSettingsService) -> None:
885
+ self.__docker_requests_service = docker_requests_service
886
+ self.__cpd_v1_image_pull_service = cpd_v1_image_pull_service
887
+ self.__cpd_v2_image_pull_service = cpd_v2_image_pull_service
888
+ self.__env_settings = env_settings
889
+
890
+ self.__os_type_override_warning_given = False
891
+ self.__arch_type_override_warning_given = False
892
+
893
+ def pull (self, image: str, tag: str, local_image_name: str, platform_variant: str = None):
894
+ # NOTE: this implementation only supports schema version v2 manifests which is compatible with all wxo specific
895
+ # images that are hosted in cloud private registry.
896
+ #
897
+ # all non-wxo images, in the non-air-gapped cpd deployment, will be pulled from public docker hub registry by
898
+ # docker compose and docker. in the air-gapped cpd case, all images (including wxo images) will be hosted in a
899
+ # private docker registry and a unique REGISTRY_URL and related credentials will be provided by said users, in
900
+ # their .env file ... which is to state that the system will go back to relying on docker to do image pulls.
901
+
902
+ logger.info(f"Pulling CPD image: {local_image_name}:{tag}")
903
+
904
+ manifests, manifest, digest = self.__docker_requests_service.get_manifest_list_or_manifest(image=image, tag=tag)
905
+
906
+ if manifests:
907
+ digest_info = self.__get_compatible_digest_info(image=image, tag=tag, manifest_list=manifests,
908
+ platform_variant=platform_variant)
909
+
910
+ digest = digest_info["digest"]
911
+ media_type = digest_info["media_type"]
912
+
913
+ manifest = self.__docker_requests_service.get_manifest_for_digest(image=image, digest=digest,
914
+ media_type=media_type)
915
+
916
+ # if "schemaVersion" in manifest and "config" in manifest and "layers" in manifest and manifest["schemaVersion"] == 2:
917
+ if manifest["schemaVersion"] == 2:
918
+ logger.debug(f"Digest: {digest}")
919
+ self.__cpd_v2_image_pull_service.pull(image=image, tag=tag, manifest=manifest, local_image_name=local_image_name)
920
+ logger.info(f"Docker image pulled - {local_image_name}:{tag}@{digest}")
921
+
922
+ else:
923
+ logger.error(f"Cannot pull V1 docker schema manifest version image for {image}:{tag}. Encountered schema version: {manifest['schemaVersion']}")
924
+ logger.error("WxO CPD docker image pulls only support V2 docker schema manifest versions, presently.")
925
+ sys.exit(1)
926
+
927
+ def __get_os_type(self) -> str:
928
+ # NOTE: we're exclusively using linux because of how ADK is set up to use colima vm and rancher. hence, it's
929
+ # always a linux VM even on mac and windows OSes.
930
+ os_type = "linux"
931
+ os_type_override = self.__env_settings.get_user_provided_docker_os_type()
932
+ if os_type_override is not None and os_type != os_type_override:
933
+ if not self.__os_type_override_warning_given:
934
+ logger.warning(f"Overriding your native docker OS type \"{os_type}\" with \"{os_type_override}\"")
935
+ self.__os_type_override_warning_given = True
936
+
937
+ os_type = os_type_override
938
+
939
+ return os_type.lower()
940
+
941
+ def __get_machine_architectures(self) -> Tuple[list[str], str, bool, bool]:
942
+ native_arch = get_architecture()
943
+ user_provided_arch = self.__env_settings.get_user_provided_docker_arch_type()
944
+ is_arm_arch = is_arm_architecture()
945
+ override = False
946
+
947
+ if user_provided_arch is not None:
948
+ user_provided_arch = user_provided_arch.lower()
949
+
950
+ if user_provided_arch is not None and user_provided_arch != native_arch:
951
+ override = True
952
+ if not self.__arch_type_override_warning_given:
953
+ logger.warning(f"Overriding your native docker machine architecture type \"{native_arch}\" with \"{user_provided_arch}\"")
954
+ self.__arch_type_override_warning_given = True
955
+
956
+ architectures = [user_provided_arch] if override else [native_arch]
957
+ if not override and is_arm_arch:
958
+ # support for rosetta.
959
+ architectures.append("amd64")
960
+
961
+ return architectures, native_arch, override, is_arm_arch
962
+
963
+ @staticmethod
964
+ def __get_os_arch_combinations(manifests: list[dict]) -> dict:
965
+ combos = {}
966
+ for manifest in manifests:
967
+ mos = manifest["platform"]["os"]
968
+ march = manifest["platform"]["architecture"]
969
+
970
+ if mos not in combos.keys():
971
+ combos[mos] = []
972
+
973
+ if march not in combos[mos]:
974
+ combos[mos].append(march)
975
+
976
+ return combos
977
+
978
+ def __get_compatible_digest_info(self, image: str, tag: str, manifest_list: list = None,
979
+ platform_variant: str = None) -> dict[str, str]:
980
+ os_type = self.__get_os_type()
981
+ archs, native_arch, is_user_provided, is_native_arm_arch = self.__get_machine_architectures()
982
+ archs_str = ", ".join([f"\"{x}\"" for x in archs]) if len(archs) > 1 else f"\"{archs[0]}\""
983
+ archs_str = f"architecture{'s' if len(archs) > 1 else ''} {archs_str}"
984
+
985
+ manifests = deepcopy(manifest_list) \
986
+ if manifest_list is not None \
987
+ else self.__docker_requests_service.get_manifests(image=image, tag=tag)
988
+
989
+ manifests = [x for x in manifest_list if
990
+ x is not None and
991
+ "mediaType" in x and
992
+ "digest" in x and
993
+ "size" in x and
994
+ "platform" in x and
995
+ "architecture" in x["platform"] and
996
+ "os" in x["platform"] and
997
+ isinstance(x["platform"]["os"], str) and
998
+ isinstance(x["platform"]["architecture"], str) and
999
+ x["platform"]["os"].lower() != "unknown" and
1000
+ x["platform"]["architecture"].lower() != "unknown"]
1001
+
1002
+ combos = self.__get_os_arch_combinations(manifests)
1003
+ supported_media_types = [DockerOCIContainerMediaTypes.V1.value, DockerOCIContainerMediaTypes.V2.value]
1004
+
1005
+ manifests = [x for x in manifests if
1006
+ x["platform"]["os"].lower() == os_type and
1007
+ x["platform"]["architecture"].lower() in archs]
1008
+
1009
+ if len(manifests) < 1:
1010
+ logger.error(f"Could not find docker manifest compatible with OS type \"{os_type}\" and {archs_str} for image {image}:{tag}.")
1011
+ logger.info(f"Available docker image OS type and machine architecture combinations: {combos}")
1012
+ # logger.info("You may override docker image pull OS type and machine architecture through using DOCKER_IMAGE_OS_TYPE and DOCKER_IMAGE_ARCH_TYPE settings in your environment file.")
1013
+ sys.exit(1)
1014
+
1015
+ elif len(manifests) > 1:
1016
+ if is_user_provided is True or not is_native_arm_arch:
1017
+ manifest_types = ", ".join(set([f"\"{x['mediaType']}\"" for x in manifests]))
1018
+ manifests = [x for x in manifests if x["mediaType"] in supported_media_types]
1019
+ if len(manifests) < 1:
1020
+ logger.error(f"Encountered unknown/incompatible manifest types ({manifest_types}) for image {image}:{tag}. Cannot pull image without compatible manifest type. Please contact support.")
1021
+ sys.exit(1)
1022
+
1023
+ manifests = [manifests[0]]
1024
+
1025
+ else:
1026
+ # native arm arch.
1027
+ native_arch_manifests = [x for x in manifests if x["platform"]["architecture"].lower() == native_arch]
1028
+ non_arch_manifests = [x for x in manifests if x["platform"]["architecture"].lower() not in get_arm_architectures()]
1029
+ other_arm_arch_manifests = [x for x in manifests if
1030
+ x["platform"]["architecture"].lower() in get_arm_architectures() and
1031
+ x["platform"]["architecture"].lower() != native_arch]
1032
+
1033
+ if len(native_arch_manifests) > 0:
1034
+ # give priority to native arm arch.
1035
+ manifests = native_arch_manifests
1036
+
1037
+ elif len(non_arch_manifests) > 0:
1038
+ # users may be using rosetta. fallback to amd64 arch by design.
1039
+ manifests = non_arch_manifests
1040
+
1041
+ else:
1042
+ # we may have encountered an arm architecture which is arm but has no supported manifests. things
1043
+ # should not get to this but it's here as a sanity check.
1044
+ logger.error(f"Encountered no compatible AMD64 and native ARM architecture manifests for image {image}:{tag}. Cannot pull image without compatible manifest type. Please contact support.")
1045
+ logger.debug(f"Available non-native ARM manifest types: {', '.join(set([x['platform']['architecture'] for x in other_arm_arch_manifests]))}")
1046
+ sys.exit(1)
1047
+
1048
+ # this is possible when dealing with arm chipsets. there may be multiple variants. in the case of multiple
1049
+ # variants, we either choose the platform variant that's provided or choose the latest (which is usually the
1050
+ # last, as per tests).
1051
+ if platform_variant is not None:
1052
+ native_arch_manifests = [x for x in manifests if "variant" in x["platform"] and x["platform"]["variant"] == platform_variant]
1053
+ if len(native_arch_manifests) < 1:
1054
+ logger.error(
1055
+ f"Could not find docker manifest compatible with OS type {os_type} and {archs_str} for image {image}:{tag} and variant {platform_variant}.")
1056
+ sys.exit(1)
1057
+
1058
+ elif len(native_arch_manifests) > 1:
1059
+ # this should never happen. but the check's here just in case.
1060
+ logger.error(
1061
+ f"Encountered multiple manifests matching OS type {os_type}, {archs_str} and variant {platform_variant} for image {image}:{tag}.")
1062
+ sys.exit(1)
1063
+
1064
+ else:
1065
+ manifests = native_arch_manifests
1066
+
1067
+ else:
1068
+ manifests = [manifests[-1]]
1069
+
1070
+ if manifests[0]['mediaType'] not in supported_media_types:
1071
+ logger.error(f"Encountered an unknown/incompatible manifest type ({manifests[0]['mediaType']}) for image {image}:{tag}. Cannot pull image without compatible manifest type. Please contact support.")
1072
+ sys.exit(1)
1073
+
1074
+ return {
1075
+ "digest" : manifests[0]["digest"],
1076
+ "media_type" : manifests[0]["mediaType"],
1077
+ }
1078
+
1079
+
53
1080
  class DockerLoginService:
54
1081
 
55
- def __init__(self, env_service: EnvService):
1082
+ def __init__ (self, env_service: EnvService):
56
1083
  self.__env_service = env_service
57
1084
 
58
1085
  def login_by_dev_edition_source(self, env_dict: dict) -> None:
@@ -65,21 +1092,42 @@ class DockerLoginService:
65
1092
  if not env_dict.get("REGISTRY_URL"):
66
1093
  raise ValueError("REGISTRY_URL is not set.")
67
1094
  registry_url = env_dict["REGISTRY_URL"].split("/")[0]
68
- if source == "internal":
1095
+ if source == DeveloperEditionSources.INTERNAL:
69
1096
  iam_api_key = env_dict.get("DOCKER_IAM_KEY")
70
1097
  if not iam_api_key:
71
1098
  raise ValueError(
72
1099
  "DOCKER_IAM_KEY is required in the environment file if WO_DEVELOPER_EDITION_SOURCE is set to 'internal'.")
73
1100
  self.__docker_login(iam_api_key, registry_url, "iamapikey")
74
- elif source == "myibm":
1101
+ elif source == DeveloperEditionSources.MYIBM:
75
1102
  wo_entitlement_key = env_dict.get("WO_ENTITLEMENT_KEY")
76
1103
  if not wo_entitlement_key:
77
1104
  raise ValueError("WO_ENTITLEMENT_KEY is required in the environment file.")
78
1105
  self.__docker_login(wo_entitlement_key, registry_url, "cp")
79
- elif source == "orchestrate":
80
- wo_auth_type = env_dict.get("WO_AUTH_TYPE")
81
- api_key, username = self.__get_docker_cred_by_wo_auth_type(auth_type=wo_auth_type, env_dict=env_dict)
82
- self.__docker_login(api_key, registry_url, username)
1106
+ elif source == DeveloperEditionSources.ORCHESTRATE:
1107
+ wo_auth_type = self.__env_service.resolve_auth_type(env_dict)
1108
+ if wo_auth_type == EnvironmentAuthType.CPD.value and not self.__env_service.did_user_provide_registry_url(env_dict):
1109
+ # docker login is not required when auth type is cpd and user has not provided a custom registry
1110
+ # URL. when in this mode, the system sets REGISTRY_URL to "cpd/cp/wxo-lite" and the system does
1111
+ # custom docker registry image pulls. when a REGISTRY_URL is provided by user and in cpd mode (i.e.,
1112
+ # the user is in air-gapped cpd deployment), it is expected that all images (wxo and non-wxo) will
1113
+ # be hosted in custom docker registry inside the air gapped cpd deployment. hence, we would want to
1114
+ # perform docker login in such a case so that images may be pulled from that custom air gapped
1115
+ # docker registry. on the flip side, when in a non-air-gapped deployment of cpd, we only need to
1116
+ # pull cpd specific images through custom docker pull implementation since cpd does not allow direct
1117
+ # ingress into services (/docker proxy in this case) which is why we rely on custom docker pull
1118
+ # implementation to pull images from a custom route in cpd cluster.
1119
+ logger.info('Authentication type is CPD and user has not provided a REGISTRY_URL. Skipping docker login.')
1120
+
1121
+ else:
1122
+ api_key, username = self.__get_docker_cred_by_wo_auth_type(auth_type=wo_auth_type, env_dict=env_dict)
1123
+ self.__docker_login(api_key, registry_url, username)
1124
+ elif source == DeveloperEditionSources.CUSTOM:
1125
+ username = env_dict.get("REGISTRY_USERNAME")
1126
+ password = env_dict.get("REGISTRY_PASSWORD")
1127
+ if not username or not password:
1128
+ logger.warning("REGISTRY_USERNAME or REGISTRY_PASSWORD are missing in the environment file. These values are needed for registry authentication when WO_DEVELOPER_EDITION_SOURCE is set to 'custom'. Skipping registry login." )
1129
+ return
1130
+ self.__docker_login(password, registry_url, username)
83
1131
 
84
1132
  @staticmethod
85
1133
  def __docker_login(api_key: str, registry_url: str, username: str = "iamapikey") -> None:
@@ -96,26 +1144,13 @@ class DockerLoginService:
96
1144
 
97
1145
  @staticmethod
98
1146
  def __get_docker_cred_by_wo_auth_type(auth_type: str | None, env_dict: dict) -> tuple[str, str]:
99
- # Try infer the auth type if not provided
100
- if not auth_type:
101
- instance_url = env_dict.get("WO_INSTANCE")
102
- if instance_url:
103
- if ".cloud.ibm.com" in instance_url:
104
- auth_type = "ibm_iam"
105
- elif ".ibm.com" in instance_url:
106
- auth_type = "mcsp"
107
- elif "https://cpd" in instance_url:
108
- auth_type = "cpd"
109
-
110
- if auth_type in {"mcsp", "ibm_iam"}:
1147
+ if auth_type in {EnvironmentAuthType.MCSP.value, EnvironmentAuthType.IBM_CLOUD_IAM.value}:
111
1148
  wo_api_key = env_dict.get("WO_API_KEY")
112
1149
  if not wo_api_key:
113
- raise ValueError(
114
- "WO_API_KEY is required in the environment file if the WO_AUTH_TYPE is set to 'mcsp' or 'ibm_iam'.")
1150
+ raise ValueError(f"WO_API_KEY is required in the environment file if the WO_AUTH_TYPE is set to '{EnvironmentAuthType.MCSP.value}' or '{EnvironmentAuthType.IBM_CLOUD_IAM.value}'.")
115
1151
  instance_url = env_dict.get("WO_INSTANCE")
116
1152
  if not instance_url:
117
- raise ValueError(
118
- "WO_INSTANCE is required in the environment file if the WO_AUTH_TYPE is set to 'mcsp' or 'ibm_iam'.")
1153
+ raise ValueError(f"WO_INSTANCE is required in the environment file if the WO_AUTH_TYPE is set to '{EnvironmentAuthType.MCSP.value}' or '{EnvironmentAuthType.IBM_CLOUD_IAM.value}'.")
119
1154
  path = urlparse(instance_url).path
120
1155
  if not path or '/' not in path:
121
1156
  raise ValueError(
@@ -126,15 +1161,13 @@ class DockerLoginService:
126
1161
  wo_api_key = env_dict.get("WO_API_KEY")
127
1162
  wo_password = env_dict.get("WO_PASSWORD")
128
1163
  if not wo_api_key and not wo_password:
129
- raise ValueError(
130
- "WO_API_KEY or WO_PASSWORD is required in the environment file if the WO_AUTH_TYPE is set to 'cpd'.")
1164
+ raise ValueError(f"WO_API_KEY or WO_PASSWORD is required in the environment file if the WO_AUTH_TYPE is set to '{EnvironmentAuthType.CPD.value}'.")
131
1165
  wo_username = env_dict.get("WO_USERNAME")
132
1166
  if not wo_username:
133
- raise ValueError("WO_USERNAME is required in the environment file if the WO_AUTH_TYPE is set to 'cpd'.")
1167
+ raise ValueError(f"WO_USERNAME is required in the environment file if the WO_AUTH_TYPE is set to '{EnvironmentAuthType.CPD.value}'.")
134
1168
  return wo_api_key or wo_password, wo_username # type: ignore[return-value]
135
1169
  else:
136
- raise ValueError(
137
- f"Unknown value for WO_AUTH_TYPE: '{auth_type}'. Must be one of ['mcsp', 'ibm_iam', 'cpd'].")
1170
+ raise ValueError(f"Unknown value for WO_AUTH_TYPE: '{auth_type}'. Must be one of ['{EnvironmentAuthType.MCSP.value}', '{EnvironmentAuthType.IBM_CLOUD_IAM.value}', '{EnvironmentAuthType.CPD.value}'].")
138
1171
 
139
1172
 
140
1173
  class DockerComposeCore:
@@ -146,6 +1179,8 @@ class DockerComposeCore:
146
1179
  base_command = self.__ensure_docker_compose_installed()
147
1180
  compose_path = self.__env_service.get_compose_file()
148
1181
 
1182
+ self.__pull_cpd_images(final_env_file=final_env_file, service_name=service_name)
1183
+
149
1184
  command = base_command + [
150
1185
  "-f", str(compose_path),
151
1186
  "--env-file", str(final_env_file),
@@ -167,6 +1202,8 @@ class DockerComposeCore:
167
1202
  compose_path = self.__env_service.get_compose_file()
168
1203
  command = self.__ensure_docker_compose_installed()[:]
169
1204
 
1205
+ self.__pull_cpd_images(final_env_file=final_env_file, service_name=None)
1206
+
170
1207
  for profile in profiles:
171
1208
  command += ["--profile", profile]
172
1209
 
@@ -278,3 +1315,110 @@ class DockerComposeCore:
278
1315
  # the purposes of reporting some info to the user.
279
1316
  typer.echo("Unable to find an installed docker-compose or docker compose")
280
1317
  sys.exit(1)
1318
+
1319
+ def __get_cpd_service_images (self, final_env_file: Path) -> list:
1320
+ rendered_yaml_file_path = None
1321
+
1322
+ try:
1323
+ base_command = self.__ensure_docker_compose_installed()
1324
+ compose_path = self.__env_service.get_compose_file()
1325
+
1326
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml", prefix="rendered-") as ntf:
1327
+ rendered_yaml_file_path = ntf.name
1328
+
1329
+ command = base_command + [
1330
+ "-f", str(compose_path),
1331
+ "--env-file", str(final_env_file),
1332
+ "config",
1333
+ "--output",
1334
+ rendered_yaml_file_path
1335
+ ]
1336
+
1337
+ cmd_result = subprocess.run(command, capture_output=False)
1338
+
1339
+ if cmd_result.returncode != 0:
1340
+ error_message = cmd_result.stderr.decode('utf-8') if cmd_result.stderr else "Error occurred."
1341
+ logger.error(error_message)
1342
+ raise Exception("Error rendering docker compose config to file")
1343
+
1344
+ with open(rendered_yaml_file_path, "rb") as rendered_yaml_file:
1345
+ rendered_compose_config = yaml_safe_load(rendered_yaml_file)
1346
+
1347
+ result = []
1348
+ for service in list(rendered_compose_config.get("services")):
1349
+ image_with_tag = rendered_compose_config["services"][service]["image"]
1350
+ if image_with_tag.startswith("cpd/"):
1351
+ temp = image_with_tag.split(":")
1352
+ if len(temp) not in (1, 2):
1353
+ # should ideally never happen but here just in case.
1354
+ logger.error(f"Failed to parse image tag for image \"{image_with_tag}\".")
1355
+ sys.exit(1)
1356
+
1357
+ result.append({
1358
+ "service" : service,
1359
+ "image": temp[0],
1360
+ "tag" : "latest" if len(temp) < 2 else temp[1],
1361
+ "core_image" : temp[0][4:]
1362
+ })
1363
+
1364
+ return result
1365
+
1366
+ finally:
1367
+ if rendered_yaml_file_path is not None:
1368
+ os.unlink(rendered_yaml_file_path)
1369
+
1370
+ def __pull_cpd_images (self, final_env_file: Path, service_name: str|None) -> None:
1371
+ env_settings = EnvSettingsService(final_env_file)
1372
+
1373
+ if (
1374
+ self.__env_service.resolve_auth_type(env_settings.get_env()) != EnvironmentAuthType.CPD.value or
1375
+ self.__env_service.did_user_provide_registry_url(env_settings.get_env())
1376
+ ):
1377
+ # no need to do custom docker image pulls when auth mode is cpd or when we're in an air-gapped cpd
1378
+ # environment (where there will be a private docker registry hosted with all the necessary images). we will
1379
+ # rely on docker compose and docker to perform docker image pulls.
1380
+ return
1381
+
1382
+ cpd_images = self.__get_cpd_service_images(final_env_file)
1383
+
1384
+ if service_name:
1385
+ service_name = service_name.strip()
1386
+ if service_name == "":
1387
+ service_name = None
1388
+
1389
+ if service_name:
1390
+ cpd_images = [x for x in cpd_images if x["service"] == service_name]
1391
+
1392
+ cpd_images = [x for x in cpd_images if not DockerUtils.image_exists_locally(image=x["image"], tag=x["tag"])]
1393
+
1394
+ if len(cpd_images) < 1:
1395
+ return
1396
+
1397
+ docker_requests_service = CpdDockerRequestsService(env_settings=env_settings,
1398
+ cpd_wxo_token_service=CpdWxOTokenService(env_settings))
1399
+
1400
+ if docker_requests_service.is_docker_proxy_up() is not True:
1401
+ logger.error("Upstream CPD Docker service is not running or is not in a ready state. Please try again later.")
1402
+ sys.exit(1)
1403
+
1404
+ cpd_image_pull_service = CpdDockerImagePullService(
1405
+ env_settings=env_settings,
1406
+ docker_requests_service=docker_requests_service,
1407
+ cpd_v1_image_pull_service=CpdDockerV1ImagePullService(docker_requests_service=docker_requests_service),
1408
+ cpd_v2_image_pull_service=CpdDockerV2ImagePullService(docker_requests_service=docker_requests_service,
1409
+ env_settings=env_settings)
1410
+ )
1411
+
1412
+ for cpd_image in cpd_images:
1413
+ try:
1414
+ cpd_image_pull_service.pull(image=cpd_image["core_image"], tag=cpd_image["tag"],
1415
+ local_image_name=cpd_image['image'])
1416
+
1417
+ except SystemExit:
1418
+ logger.error(f"Failed to pull CPD docker image {cpd_image['core_image']}:{cpd_image['tag']}")
1419
+ raise
1420
+
1421
+ except Exception as ex:
1422
+ logger.error(ex)
1423
+ logger.error(f"Failed to pull CPD docker image {cpd_image['core_image']}:{cpd_image['tag']}")
1424
+ sys.exit(1)