docker.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. """Docker utilities."""
  10. import re
  11. import subprocess
  12. import time
  13. import requests
  14. from materialize.mz_version import MzVersion
  15. CACHED_IMAGE_NAME_BY_COMMIT_HASH: dict[str, str] = dict()
  16. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK: dict[str, bool] = dict()
  17. IMAGE_TAG_OF_DEV_VERSION_METADATA_SEPARATOR = "--"
  18. LATEST_IMAGE_TAG = "latest"
  19. LEGACY_IMAGE_TAG_COMMIT_PREFIX = "devel-"
  20. # Examples:
  21. # * v0.114.0
  22. # * v0.114.0-dev
  23. # * v0.114.0-dev.0--pr.g3d565dd11ba1224a41beb6a584215d99e6b3c576
  24. VERSION_IN_IMAGE_TAG_PATTERN = re.compile(r"^(v\d+\.\d+\.\d+(-dev)?)")
  25. def image_of_release_version_exists(version: MzVersion) -> bool:
  26. if version.is_dev_version():
  27. raise ValueError(f"Version {version} is a dev version, not a release version")
  28. return _mz_image_tag_exists(release_version_to_image_tag(version))
  29. def image_of_commit_exists(commit_hash: str) -> bool:
  30. return _mz_image_tag_exists(commit_to_image_tag(commit_hash))
  31. def _mz_image_tag_exists(image_tag: str) -> bool:
  32. image_name = f"materialize/materialized:{image_tag}"
  33. if image_name in EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK:
  34. image_exists = EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name]
  35. print(
  36. f"Status of image {image_name} known from earlier check: {'exists' if image_exists else 'does not exist'}"
  37. )
  38. return image_exists
  39. print(f"Checking existence of image manifest: {image_name}")
  40. command_local = ["docker", "images", "--quiet", image_name]
  41. output = subprocess.check_output(command_local, stderr=subprocess.STDOUT, text=True)
  42. if output:
  43. # image found locally, can skip querying remote Docker Hub
  44. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = True
  45. return True
  46. # docker manifest inspect counts against the Docker Hub rate limits, even
  47. # when the image doesn't exist, see https://www.docker.com/increase-rate-limits/,
  48. # so use the API instead.
  49. try:
  50. response = requests.get(
  51. f"https://hub.docker.com/v2/repositories/materialize/materialized/tags/{image_tag}"
  52. )
  53. result = response.json()
  54. except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError):
  55. command = [
  56. "docker",
  57. "manifest",
  58. "inspect",
  59. image_name,
  60. ]
  61. try:
  62. subprocess.check_output(command, stderr=subprocess.STDOUT, text=True)
  63. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = True
  64. return True
  65. except subprocess.CalledProcessError as e:
  66. if "no such manifest:" in e.output:
  67. print(f"Failed to fetch image manifest '{image_name}' (does not exist)")
  68. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = False
  69. else:
  70. print(f"Failed to fetch image manifest '{image_name}' ({e.output})")
  71. # do not cache the result of unknown error messages
  72. return False
  73. if result.get("images"):
  74. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = True
  75. return True
  76. if "not found" in result.get("message", ""):
  77. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = False
  78. return False
  79. print(f"Failed to fetch image info from API: {result}")
  80. # do not cache the result of unknown error messages
  81. return False
  82. def commit_to_image_tag(commit_hash: str) -> str:
  83. return _resolve_image_name_by_commit_hash(commit_hash)
  84. def release_version_to_image_tag(version: MzVersion) -> str:
  85. return str(version)
  86. def is_image_tag_of_release_version(image_tag: str) -> bool:
  87. return (
  88. IMAGE_TAG_OF_DEV_VERSION_METADATA_SEPARATOR not in image_tag
  89. and not image_tag.startswith(LEGACY_IMAGE_TAG_COMMIT_PREFIX)
  90. and image_tag != LATEST_IMAGE_TAG
  91. )
  92. def is_image_tag_of_commit(image_tag: str) -> bool:
  93. return (
  94. IMAGE_TAG_OF_DEV_VERSION_METADATA_SEPARATOR in image_tag
  95. or image_tag.startswith(LEGACY_IMAGE_TAG_COMMIT_PREFIX)
  96. )
  97. def get_version_from_image_tag(image_tag: str) -> str:
  98. match = VERSION_IN_IMAGE_TAG_PATTERN.match(image_tag)
  99. assert match is not None, f"Invalid image tag: {image_tag}"
  100. return match.group(1)
  101. def get_mz_version_from_image_tag(image_tag: str) -> MzVersion:
  102. return MzVersion.parse_mz(get_version_from_image_tag(image_tag))
  103. def _resolve_image_name_by_commit_hash(commit_hash: str) -> str:
  104. if commit_hash in CACHED_IMAGE_NAME_BY_COMMIT_HASH.keys():
  105. return CACHED_IMAGE_NAME_BY_COMMIT_HASH[commit_hash]
  106. image_name_candidates = _search_docker_hub_for_image_name(search_value=commit_hash)
  107. image_name = _select_image_name_from_candidates(image_name_candidates, commit_hash)
  108. CACHED_IMAGE_NAME_BY_COMMIT_HASH[commit_hash] = image_name
  109. EXISTENCE_OF_IMAGE_NAMES_FROM_EARLIER_CHECK[image_name] = True
  110. return image_name
  111. def _search_docker_hub_for_image_name(
  112. search_value: str, remaining_retries: int = 10
  113. ) -> list[str]:
  114. try:
  115. json_response = requests.get(
  116. f"https://hub.docker.com/v2/repositories/materialize/materialized/tags?name={search_value}"
  117. ).json()
  118. except (
  119. requests.exceptions.ConnectionError,
  120. requests.exceptions.JSONDecodeError,
  121. ) as _:
  122. if remaining_retries > 0:
  123. print("Searching Docker Hub for image name failed, retrying in 5 seconds")
  124. time.sleep(5)
  125. return _search_docker_hub_for_image_name(
  126. search_value, remaining_retries - 1
  127. )
  128. raise
  129. json_results = json_response.get("results")
  130. image_names = []
  131. for entry in json_results:
  132. image_name = entry.get("name")
  133. if image_name.startswith("unstable-"):
  134. # for images with the old version scheme favor "devel-" over "unstable-"
  135. continue
  136. image_names.append(image_name)
  137. return image_names
  138. def _select_image_name_from_candidates(
  139. image_name_candidates: list[str], commit_hash: str
  140. ) -> str:
  141. if len(image_name_candidates) == 0:
  142. raise RuntimeError(f"No image found for commit hash {commit_hash}")
  143. if len(image_name_candidates) > 1:
  144. print(
  145. f"Multiple images found for commit hash {commit_hash}: {image_name_candidates}, picking first"
  146. )
  147. return image_name_candidates[0]