package_downloader.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. #
  2. # Copyright (c) Contributors to the Open 3D Engine Project.
  3. # For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import os
  9. import urllib
  10. import urllib.request
  11. from urllib.error import URLError
  12. import ssl
  13. import certifi
  14. import hashlib
  15. import pathlib
  16. import tarfile
  17. import sys
  18. from urllib.parse import _splithost
  19. # used if LY_PACKAGE_SERVER_URLS is not set.
  20. DEFAULT_LY_PACKAGE_SERVER_URLS = "https://d2c171ws20a1rv.cloudfront.net;https://d3t6xeg4fgfoum.cloudfront.net"
  21. possible_download_errors = (ssl.SSLError, URLError, OSError)
  22. # its not necessarily the case that you ever actually have to use boto3
  23. # if all the servers you specify in your server list (Default above) are
  24. # not s3 buckets. So it is not a failure to be missing boto3 unless you actually
  25. # try to use it later.
  26. _aws_s3_available = False
  27. try:
  28. import boto3
  29. from botocore.exceptions import BotoCoreError
  30. from botocore.exceptions import ClientError
  31. _aws_s3_available = True
  32. possible_download_errors = possible_download_errors + (ClientError, BotoCoreError)
  33. except:
  34. print("Could not import boto3 (pip install boto3) - downloading from S3 buckets will not function")
  35. pass
  36. class PackageDownloader():
  37. @staticmethod
  38. def ComputeHashOfFile(file_path):
  39. '''
  40. Compute a sha256 hex-encoded hash for the contents of a file represented by file_path
  41. '''
  42. file_path = os.path.normpath(file_path)
  43. hasher = hashlib.sha256()
  44. hash_result = None
  45. # we don't follow symlinks here, this is strictly to check actual packages.
  46. with open(file_path, 'rb') as afile:
  47. buf = afile.read()
  48. hasher.update(buf)
  49. hash_result = hasher.hexdigest()
  50. return hash_result
  51. def ValidateUnpackedPackage(package_name, package_hash, folder_target):
  52. '''
  53. This function will determine the integrity of a download and unpacked package.
  54. Given a package name, hash, and folder where a package was previously unpacked,
  55. this will verify the package's SHA256SUMS integrity file against the files in the
  56. folder. In there are any files missing or corrupted, then the function will return
  57. False, otherwise it will return True.
  58. '''
  59. download_location = pathlib.Path(folder_target)
  60. package_unpack_folder = download_location / package_name
  61. if not package_unpack_folder.is_dir():
  62. return False;
  63. sha256_sums_file_path = package_unpack_folder / 'SHA256SUMS'
  64. if not sha256_sums_file_path.is_file():
  65. return False;
  66. with sha256_sums_file_path.open() as sha256_sums_file:
  67. sha256_sums = sha256_sums_file.readlines()
  68. for sha256_sum_line in sha256_sums:
  69. sha256_sum, src_file = sha256_sum_line.split(' *')
  70. src_file_full_path = package_unpack_folder / src_file.strip()
  71. if not src_file_full_path.is_file():
  72. return False
  73. computed_hash = PackageDownloader.ComputeHashOfFile(str(src_file_full_path))
  74. if computed_hash != sha256_sum:
  75. print(f"Existing package {package_name} not valid ({src_file} sum doesnt match)")
  76. return False
  77. return True
  78. @staticmethod
  79. def DownloadAndUnpackPackage(package_name, package_hash, folder_target):
  80. '''Given a package name, hash, and folder to unzip it into,
  81. attempts to find the package. If found, downloads and unpacks to the target_folder location.
  82. Only the first found package is downloaded, and then the search stops. If the checksum of
  83. the downloaded file doesn't match the checksum in the O3DE dependency list, the package
  84. isn't unpacked on the filesystem and the download is deleted.
  85. This method supports all URI types handled by the O3DE package system, including S3 URIs.
  86. PRECONDITIONS:
  87. * LY_PACKAGE_SERVER_URLS must be set in the environment to override the defaultg
  88. * If using S3 URIs, LY_AWS_PROFILE must be set in the environment and the 'aws' command
  89. must be on the PATH
  90. Returns True if successful, False otherwise.
  91. '''
  92. # make sure a package with that name is not already present:
  93. server_urls = os.environ.get("LY_PACKAGE_SERVER_URLS", default = "")
  94. if not server_urls:
  95. print(f"Server url list is empty - please set LY_PACKAGE_SERVER_URLS env var to semicolon-seperated list of urls to check")
  96. print(f"Using default URL for convenience: {DEFAULT_LY_PACKAGE_SERVER_URLS}")
  97. server_urls = DEFAULT_LY_PACKAGE_SERVER_URLS
  98. download_location = pathlib.Path(folder_target)
  99. package_file_name = package_name + ".tar.xz"
  100. package_download_name = download_location / package_file_name
  101. package_unpack_folder = download_location / package_name
  102. server_list = server_urls.split(';')
  103. try:
  104. package_download_name.unlink()
  105. except FileNotFoundError:
  106. pass
  107. download_location.mkdir(parents=True, exist_ok=True)
  108. print(f"Downloading package {package_name}...")
  109. for package_server in server_list:
  110. if not package_server:
  111. continue
  112. full_package_url = package_server + "/" + package_file_name
  113. try:
  114. # check if its a local file (gets around an issue with parsing urls in py3.10.x)
  115. parse_result = urllib.parse.urlparse(full_package_url)
  116. if parse_result.scheme == 'file':
  117. actual_path = ""
  118. if parse_result.netloc:
  119. actual_path = urllib.request.url2pathname(parse_result.netloc + parse_result.path)
  120. else:
  121. actual_path = urllib.request.url2pathname(parse_result.path)
  122. # 'download' a local file:
  123. file_data = None
  124. print(f" - Reading from local file: {actual_path}")
  125. with open(actual_path, "rb") as input_file:
  126. file_data = input_file.read()
  127. with open(package_download_name, "wb") as save_package:
  128. save_package.write(file_data)
  129. elif full_package_url.startswith("s3://"):
  130. if not _aws_s3_available:
  131. print(f"S3 URL given, but boto3 could not be located. Please ensure that you have installed")
  132. print(f"installed requirements: {sys.executable} -m pip install --upgrade boto3 certifi six")
  133. continue
  134. # it may be legitimate not have a blank AWS profile, so we can't supply a default here
  135. aws_profile_name = os.environ.get("LY_AWS_PROFILE", default = "")
  136. # if it is blank, its still worth noting in the log:
  137. if not aws_profile_name:
  138. print(" - LY_AWS_PROFILE env var is not set - using blank AWS profile by default")
  139. session = boto3.session.Session(profile_name=aws_profile_name)
  140. bucket_name = full_package_url[len("s3://"):]
  141. slash_pos = bucket_name.find('/')
  142. if slash_pos != -1:
  143. bucket_name = bucket_name[:slash_pos]
  144. print(f" - using aws to download {package_file_name} from bucket {bucket_name}...")
  145. session.client('s3').download_file(bucket_name, package_file_name, str(package_download_name))
  146. else:
  147. tls_context = ssl.create_default_context(cafile=certifi.where())
  148. print(f" - Trying URL: {full_package_url}")
  149. with urllib.request.urlopen(url=full_package_url, context = tls_context) as server_response:
  150. file_data = server_response.read()
  151. with open(package_download_name, "wb") as save_package:
  152. save_package.write(file_data)
  153. except possible_download_errors as e:
  154. print(f" - Unable to get package from this server: {e}")
  155. continue # try the next URL, if any...
  156. try:
  157. # validate that the package matches its hash
  158. print(" - Checking hash ... ")
  159. hash_result = PackageDownloader.ComputeHashOfFile(str(package_download_name))
  160. if hash_result != package_hash:
  161. print(" - Warning: Hash of package does not match - will not use it")
  162. continue
  163. # hash matched. Unpack and return!
  164. package_unpack_folder.mkdir(parents=True, exist_ok=True)
  165. with tarfile.open(package_download_name) as archive_file:
  166. print(" - unpacking package...")
  167. archive_file.extractall(package_unpack_folder)
  168. print(f"Downloaded successfuly to {os.path.realpath(package_unpack_folder)}")
  169. return True
  170. except (OSError, tarfile.TarError) as e:
  171. # note that anything that causes this to fail should result in trying the next one.
  172. print(f" - unable to unpack or verify the package: {e}")
  173. continue # try the next server, if you have any
  174. finally:
  175. # clean up
  176. if os.path.exists(package_download_name):
  177. try:
  178. os.remove(package_download_name)
  179. except:
  180. pass
  181. print("FAILED - unable to find the package on any servers.")
  182. return False
  183. # you can also use this module from a bash script to get a package
  184. if __name__ == '__main__':
  185. import argparse
  186. parser = argparse.ArgumentParser(description="Download, verify hash, and unpack a 3p package")
  187. parser.add_argument('--package-name',
  188. help='The package name to download',
  189. required=True)
  190. parser.add_argument('--package-hash',
  191. help='The package hash to verify',
  192. required=True)
  193. parser.add_argument('--output-folder',
  194. help='The folder to unpack to. It will get unpacked into (package-name) subfolder!',
  195. required=True)
  196. parsed_args = parser.parse_args()
  197. if PackageDownloader.DownloadAndUnpackPackage(parsed_args.package_name, parsed_args.package_hash, parsed_args.output_folder):
  198. sys.exit(0)
  199. sys.exit(1)