cloudbridge 4.3.0__tar.gz → 4.3.1__tar.gz

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 (73) hide show
  1. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/PKG-INFO +1 -1
  2. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/resources.py +52 -34
  4. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/resources.py +6 -2
  5. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/resources.py +2 -2
  6. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/services.py +49 -10
  7. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/resources.py +2 -2
  8. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/PKG-INFO +1 -1
  9. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/SOURCES.txt +2 -0
  10. cloudbridge-4.3.1/tests/test_aws_dns_waiters.py +141 -0
  11. cloudbridge-4.3.1/tests/test_aws_vm_types.py +166 -0
  12. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_download_driver.py +70 -0
  13. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/LICENSE +0 -0
  14. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/README.rst +0 -0
  15. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/__init__.py +0 -0
  16. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/helpers.py +0 -0
  17. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/middleware.py +0 -0
  18. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/provider.py +0 -0
  19. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/services.py +0 -0
  20. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/base/subservices.py +0 -0
  21. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/factory.py +0 -0
  22. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/__init__.py +0 -0
  23. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/exceptions.py +0 -0
  24. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/provider.py +0 -0
  25. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/services.py +0 -0
  26. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/interfaces/subservices.py +0 -0
  27. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/__init__.py +0 -0
  28. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/__init__.py +0 -0
  29. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/helpers.py +0 -0
  30. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/provider.py +0 -0
  31. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/aws/subservices.py +0 -0
  32. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/__init__.py +0 -0
  33. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/azure_client.py +0 -0
  34. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/helpers.py +0 -0
  35. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/provider.py +0 -0
  36. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/services.py +0 -0
  37. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/azure/subservices.py +0 -0
  38. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/__init__.py +0 -0
  39. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/helpers.py +0 -0
  40. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/provider.py +0 -0
  41. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/resources.py +0 -0
  42. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/services.py +0 -0
  43. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/gcp/subservices.py +0 -0
  44. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/mock/__init__.py +0 -0
  45. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/mock/provider.py +0 -0
  46. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/__init__.py +0 -0
  47. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/helpers.py +0 -0
  48. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/provider.py +0 -0
  49. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/resources.py +0 -0
  50. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/services.py +0 -0
  51. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/providers/openstack/subservices.py +0 -0
  52. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge/py.typed +0 -0
  53. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/dependency_links.txt +0 -0
  54. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/requires.txt +0 -0
  55. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/cloudbridge.egg-info/top_level.txt +0 -0
  56. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/pyproject.toml +0 -0
  57. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/setup.cfg +0 -0
  58. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_base_helpers.py +0 -0
  59. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_block_store_service.py +0 -0
  60. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_cloud_factory.py +0 -0
  61. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_cloud_helpers.py +0 -0
  62. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_compute_service.py +0 -0
  63. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_dns_service.py +0 -0
  64. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_image_service.py +0 -0
  65. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_interface.py +0 -0
  66. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_middleware_system.py +0 -0
  67. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_multipart_driver.py +0 -0
  68. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_network_service.py +0 -0
  69. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_object_life_cycle.py +0 -0
  70. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_object_store_service.py +0 -0
  71. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_region_service.py +0 -0
  72. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_security_service.py +0 -0
  73. {cloudbridge-4.3.0 → cloudbridge-4.3.1}/tests/test_vm_types_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.3.0
3
+ Version: 4.3.1
4
4
  Summary: A simple layer of abstraction over multiple cloud providers.
5
5
  Author-email: Galaxy and GVL Projects <help@genome.edu.au>
6
6
  License-Expression: MIT
@@ -3,7 +3,7 @@ import logging
3
3
  from typing import Any
4
4
 
5
5
  # Current version of the library
6
- __version__ = '4.3.0'
6
+ __version__ = '4.3.1'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -9,6 +9,7 @@ import os
9
9
  import queue
10
10
  import re
11
11
  import shutil
12
+ import threading
12
13
  import time
13
14
  import uuid
14
15
  from concurrent.futures import FIRST_COMPLETED
@@ -886,14 +887,43 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
886
887
 
887
888
  def download_to_file(self, path: str,
888
889
  config: TransferConfig | None = None) -> None:
890
+ # Assemble the object in a private file alongside the destination and
891
+ # rename it into place once complete, so ``path`` only ever holds a
892
+ # whole object. Callers commonly download every copy of an object to
893
+ # one well-known path (a cache entry, say), so writing in place would
894
+ # let concurrent downloads truncate each other's file - or rename it
895
+ # away mid-transfer - and would destroy a previously downloaded copy
896
+ # when a transfer fails.
897
+ part_path = f"{path}.{uuid.uuid4().hex}.cbpart"
898
+ try:
899
+ self._download_to_path(part_path, config)
900
+ os.replace(part_path, path)
901
+ except BaseException:
902
+ try:
903
+ os.remove(part_path)
904
+ except OSError:
905
+ pass
906
+ raise
907
+
908
+ def _download_to_path(self, path: str,
909
+ config: TransferConfig | None = None) -> None:
910
+ """
911
+ Write this object's content to ``path``, which the caller owns.
912
+
913
+ Providers with an efficient, thread-safe native downloader (e.g. AWS
914
+ via boto3's ``download_file``, Azure via ``download_blob``) override
915
+ this to use it; the default implementation streams small objects and
916
+ fetches larger ones as parallel ranged reads.
917
+ """
889
918
  size = self.size
890
919
  if size <= self._multipart_threshold(config):
891
920
  with open(path, 'wb') as f:
892
921
  self.save_content(f)
893
922
  return
894
- self._download_ranged(path, size, config)
923
+ with open(path, 'w+b') as f:
924
+ self._download_ranged(f, size, config)
895
925
 
896
- def _download_ranged(self, path: str, size: int,
926
+ def _download_ranged(self, target: IO[bytes], size: int,
897
927
  config: TransferConfig | None = None) -> None:
898
928
  """
899
929
  Fetch the object as ranged reads across a bounded thread pool,
@@ -902,40 +932,26 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
902
932
  To stay safe even on providers whose SDK client/connection is not
903
933
  thread-safe, each worker reads through its own cloned provider (see
904
934
  :meth:`.CloudProvider.clone`), so no provider state is shared between
905
- threads. Memory is bounded to ~concurrency * part_size. On any
906
- failure the partial file is removed and the error re-raised.
907
-
908
- Providers with an efficient, thread-safe native downloader (e.g. AWS
909
- via boto3's ``download_file``, Azure via ``download_blob``) override
910
- ``download_to_file`` to use it directly.
935
+ threads. Memory is bounded to ~concurrency * part_size.
911
936
  """
912
937
  part_size = self._multipart_part_size(config)
913
938
  if part_size < 1:
914
939
  raise InvalidValueException('part_size', part_size)
915
940
  concurrency = max(1, self._multipart_max_concurrency(config))
941
+ target.truncate(size)
916
942
  ranges = [(offset, min(part_size, size - offset))
917
943
  for offset in range(0, size, part_size)]
918
- try:
919
- with open(path, 'wb') as f:
920
- f.truncate(size)
921
- if concurrency == 1:
922
- bucket_objects = self._bucket_objects
923
- with open(path, 'r+b') as f:
924
- for offset, length in ranges:
925
- f.seek(offset)
926
- f.write(bucket_objects.download_range(
927
- self.bucket, self.name, offset, length))
928
- else:
929
- self._download_ranges_concurrently(path, ranges, concurrency)
930
- except Exception:
931
- try:
932
- os.remove(path)
933
- except OSError:
934
- pass
935
- raise
944
+ if concurrency == 1:
945
+ bucket_objects = self._bucket_objects
946
+ for offset, length in ranges:
947
+ target.seek(offset)
948
+ target.write(bucket_objects.download_range(
949
+ self.bucket, self.name, offset, length))
950
+ else:
951
+ self._download_ranges_concurrently(target, ranges, concurrency)
936
952
 
937
953
  def _download_ranges_concurrently(
938
- self, path: str, ranges: list[tuple[int, int]],
954
+ self, target: IO[bytes], ranges: list[tuple[int, int]],
939
955
  concurrency: int) -> None:
940
956
  # A pool of cloned bucket-object services, one per worker, so each
941
957
  # thread touches an isolated provider/connection.
@@ -947,6 +963,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
947
963
 
948
964
  bucket = self.bucket
949
965
  name = self.name
966
+ write_lock = threading.Lock()
950
967
 
951
968
  def fetch_one(offset: int, length: int) -> None:
952
969
  service = clones.get()
@@ -954,13 +971,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
954
971
  data = service.download_range(bucket, name, offset, length)
955
972
  finally:
956
973
  clones.put(service)
957
- # Each worker writes through its own handle at its own offset;
958
- # ranges never overlap, so no locking is needed. Data is released
959
- # as soon as it is written, bounding memory to
960
- # ~concurrency * part_size.
961
- with open(path, 'r+b') as f:
962
- f.seek(offset)
963
- f.write(data)
974
+ # Ranges are fetched in parallel but written through the one
975
+ # handle the caller opened, so a range can never be written to a
976
+ # file that has since been replaced. Serializing the writes costs
977
+ # little next to the fetches, and the data is released as soon as
978
+ # it is written, bounding memory to ~concurrency * part_size.
979
+ with write_lock:
980
+ target.seek(offset)
981
+ target.write(data)
964
982
 
965
983
  with ThreadPoolExecutor(max_workers=concurrency) as executor:
966
984
  futures = [executor.submit(fetch_one, offset, length)
@@ -2388,8 +2388,12 @@ class BucketObject(CloudResource):
2388
2388
  remain single-stream alternatives for arbitrary target streams.
2389
2389
 
2390
2390
  :type path: ``str``
2391
- :param path: Local path to write the object's content to. An existing
2392
- file is overwritten; on failure no partial file is left behind.
2391
+ :param path: Local path to write the object's content to. The object
2392
+ is assembled out of the way and moved into place once complete,
2393
+ so ``path`` never holds a partial object: an existing file is
2394
+ replaced atomically, and a failed transfer leaves it untouched.
2395
+ Concurrent downloads to one path are therefore safe, with the
2396
+ last to complete winning.
2393
2397
 
2394
2398
  :type config: :class:`.TransferConfig`
2395
2399
  :param config: Optional per-call transfer tuning (threshold, part
@@ -944,8 +944,8 @@ class AWSBucketObject(BaseBucketObject):
944
944
  self._obj.upload_file(path, Config=transfer_config)
945
945
  return self
946
946
 
947
- def download_to_file(self, path: str,
948
- config: TransferConfig | None = None) -> None:
947
+ def _download_to_path(self, path: str,
948
+ config: TransferConfig | None = None) -> None:
949
949
  # boto3's TransferManager downloads large objects as parallel ranged
950
950
  # GETs with a thread-safe client, so the transparent ranged path
951
951
  # delegates to it rather than CloudBridge's generic clone-pool driver.
@@ -1023,6 +1023,9 @@ class AWSVMTypeService(BaseVMTypeService):
1023
1023
 
1024
1024
  def __init__(self, provider: CloudProvider) -> None:
1025
1025
  super(AWSVMTypeService, self).__init__(provider)
1026
+ # Raw instance type dicts, keyed by availability zone. See
1027
+ # _get_catalogue for why this is memoised.
1028
+ self._catalogue: dict[str | None, list[dict[str, Any]]] = {}
1026
1029
 
1027
1030
  @dispatch(event="provider.compute.vm_types.get",
1028
1031
  priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
@@ -1039,15 +1042,12 @@ class AWSVMTypeService(BaseVMTypeService):
1039
1042
  else:
1040
1043
  raise e
1041
1044
 
1042
- @dispatch(event="provider.compute.vm_types.list",
1043
- priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1044
- def list(self, limit: int | None = None,
1045
- marker: str | None = None) -> ResultList[VMType]:
1045
+ def _fetch_catalogue(self, zone: str | None) -> list[dict[str, Any]]:
1046
1046
  client = cast("AWSCloudProvider", self.provider).ec2_conn.meta.client
1047
1047
  vmt_list_resp = client.describe_instance_type_offerings(
1048
1048
  LocationType='availability-zone',
1049
1049
  Filters=[{'Name': 'location',
1050
- 'Values': [self.provider.zone_name]}],
1050
+ 'Values': [zone]}],
1051
1051
  # MaxResults is set to max value (1000)
1052
1052
  # and client-side pagination is used
1053
1053
  **trim_empty_params({'MaxResults': 1000, 'NextToken': None}))
@@ -1056,7 +1056,7 @@ class AWSVMTypeService(BaseVMTypeService):
1056
1056
  vmt_list_resp = client.describe_instance_type_offerings(
1057
1057
  LocationType='availability-zone',
1058
1058
  Filters=[{'Name': 'location',
1059
- 'Values': [self.provider.zone_name]}],
1059
+ 'Values': [zone]}],
1060
1060
  **trim_empty_params(
1061
1061
  {'MaxResults': 1000,
1062
1062
  'NextToken': vmt_list_resp.get("NextToken")}))
@@ -1067,12 +1067,41 @@ class AWSVMTypeService(BaseVMTypeService):
1067
1067
  # describe_instance_types call can get at most 100 types at once
1068
1068
  chunks = [vmt_list_names[x:x + 100]
1069
1069
  for x in range(0, len(vmt_list_names), 100)]
1070
- raw_types = []
1070
+ raw_types: list[dict[str, Any]] = []
1071
1071
  for chunk in chunks:
1072
1072
  raw_chunk = client.describe_instance_types(
1073
1073
  InstanceTypes=chunk).get('InstanceTypes')
1074
1074
  raw_types.extend(raw_chunk)
1075
- cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t) for t in raw_types]
1075
+ return raw_types
1076
+
1077
+ def _get_catalogue(self) -> list[dict[str, Any]]:
1078
+ """
1079
+ Return the raw instance type catalogue for the provider's zone,
1080
+ fetching it at most once per zone.
1081
+
1082
+ EC2 has no server-side paging for instance types, so ``list()`` must
1083
+ materialise the whole catalogue and page it client-side. Fetching it
1084
+ costs one ``DescribeInstanceTypeOfferings`` walk plus one
1085
+ ``DescribeInstanceTypes`` call per 100 types — around 14 calls for a
1086
+ real region. Without memoisation every page of a ``list(marker=...)``
1087
+ walk repeats all of that, making a full walk quadratic in API calls:
1088
+ 1343 types at a result limit of 5 costs ~4300 calls instead of ~14.
1089
+
1090
+ The catalogue is static for the lifetime of a provider, so it is held
1091
+ per zone (a provider may be cloned to another zone, which genuinely
1092
+ offers a different set of types).
1093
+ """
1094
+ zone = self.provider.zone_name
1095
+ if zone not in self._catalogue:
1096
+ self._catalogue[zone] = self._fetch_catalogue(zone)
1097
+ return self._catalogue[zone]
1098
+
1099
+ @dispatch(event="provider.compute.vm_types.list",
1100
+ priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
1101
+ def list(self, limit: int | None = None,
1102
+ marker: str | None = None) -> ResultList[VMType]:
1103
+ cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t)
1104
+ for t in self._get_catalogue()]
1076
1105
  return ClientPagedResultList(self.provider, cb_types,
1077
1106
  limit=limit, marker=marker)
1078
1107
 
@@ -1674,6 +1703,14 @@ class AWSDnsZoneService(BaseDnsZoneService):
1674
1703
  client.delete_hosted_zone(Id=dns_zone.aws_id)
1675
1704
 
1676
1705
 
1706
+ # Route53 reports record changes INSYNC within seconds, but boto3's
1707
+ # resource_record_sets_changed waiter polls every 30s by default, so every
1708
+ # record change costs a full 30s of sleep no matter how fast it propagated.
1709
+ # Poll often enough that the granularity stops dominating, while keeping the
1710
+ # same ~30 minute ceiling for changes that genuinely are slow.
1711
+ DNS_CHANGE_WAITER_CONFIG = {'Delay': 5, 'MaxAttempts': 360}
1712
+
1713
+
1677
1714
  class AWSDnsRecordService(BaseDnsRecordService):
1678
1715
 
1679
1716
  def __init__(self, provider: CloudProvider) -> None:
@@ -1765,7 +1802,8 @@ class AWSDnsRecordService(BaseDnsRecordService):
1765
1802
  # waiting, this is skipped for mock tests.
1766
1803
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1767
1804
  waiter = client.get_waiter('resource_record_sets_changed')
1768
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1805
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1806
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
1769
1807
  return cast(DnsRecord, self.get(dns_zone, name + ":" + type))
1770
1808
 
1771
1809
  def delete(self, dns_zone: DnsZone | str,
@@ -1793,4 +1831,5 @@ class AWSDnsRecordService(BaseDnsRecordService):
1793
1831
  # waiting, this is skipped for mock tests.
1794
1832
  if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
1795
1833
  waiter = client.get_waiter('resource_record_sets_changed')
1796
- waiter.wait(Id=response.get('ChangeInfo').get('Id'))
1834
+ waiter.wait(Id=response.get('ChangeInfo').get('Id'),
1835
+ WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
@@ -302,8 +302,8 @@ class AzureBucketObject(BaseBucketObject):
302
302
  max_concurrency=self._multipart_max_concurrency(config))
303
303
  return self
304
304
 
305
- def download_to_file(self, path: str,
306
- config: TransferConfig | None = None) -> None:
305
+ def _download_to_path(self, path: str,
306
+ config: TransferConfig | None = None) -> None:
307
307
  # azure-storage-blob's downloader fetches block ranges concurrently
308
308
  # with a thread-safe client, so delegate to it rather than
309
309
  # CloudBridge's generic clone-pool driver.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudbridge
3
- Version: 4.3.0
3
+ Version: 4.3.1
4
4
  Summary: A simple layer of abstraction over multiple cloud providers.
5
5
  Author-email: Galaxy and GVL Projects <help@genome.edu.au>
6
6
  License-Expression: MIT
@@ -51,6 +51,8 @@ cloudbridge/providers/openstack/provider.py
51
51
  cloudbridge/providers/openstack/resources.py
52
52
  cloudbridge/providers/openstack/services.py
53
53
  cloudbridge/providers/openstack/subservices.py
54
+ tests/test_aws_dns_waiters.py
55
+ tests/test_aws_vm_types.py
54
56
  tests/test_base_helpers.py
55
57
  tests/test_block_store_service.py
56
58
  tests/test_cloud_factory.py
@@ -0,0 +1,141 @@
1
+ """
2
+ Unit tests for how ``AWSDnsRecordService`` waits on Route53 changes.
3
+
4
+ Creating or deleting a record blocks until Route53 reports the change INSYNC.
5
+ boto3's ``resource_record_sets_changed`` waiter polls every 30 seconds by
6
+ default, so a change that propagates in a few seconds still costs a full 30 --
7
+ and a test that makes four record changes pays 120 seconds of pure sleep.
8
+ Measured against real Route53, INSYNC was reached within the first poll
9
+ interval every time, making the granularity the entire cost.
10
+
11
+ These tests drive the real botocore waiter against a simulated clock, so they
12
+ assert on how long we *would* sleep without actually sleeping.
13
+ """
14
+ import unittest
15
+ from unittest import mock
16
+
17
+ import botocore.client
18
+ import botocore.waiter
19
+ from botocore.exceptions import WaiterError
20
+
21
+ from cloudbridge.providers.aws import AWSCloudProvider
22
+ from cloudbridge.providers.aws.resources import AWSDnsRecord
23
+ from cloudbridge.providers.aws.resources import AWSDnsZone
24
+ from cloudbridge.providers.aws.services import AWSDnsRecordService
25
+
26
+ # Simulated seconds before Route53 reports INSYNC. Real-world measurement put
27
+ # this comfortably inside one 30s poll interval.
28
+ INSYNC_AFTER = 6.0
29
+ BOTO_DEFAULT_DELAY = 30.0
30
+ # The waiter's ceiling must stay at roughly 30 minutes however it is polled.
31
+ REQUIRED_CEILING = 1700.0
32
+
33
+ ZONE = {'Id': '/hostedzone/Z1EXAMPLE', 'Name': 'example.com.'}
34
+ RECORD = {'Name': 'sub.example.com.', 'Type': 'CNAME', 'TTL': 500,
35
+ 'ResourceRecords': [{'Value': 'hello.com.'}]}
36
+
37
+
38
+ class _Route53Sim:
39
+ """Canned Route53 responses driven by a simulated clock."""
40
+
41
+ def __init__(self, insync_after=INSYNC_AFTER):
42
+ self.insync_after = insync_after
43
+ self.clock = 0.0
44
+ self.sleeps = []
45
+ self.get_change_calls = 0
46
+
47
+ def api(self, operation_name, params):
48
+ if operation_name == 'ChangeResourceRecordSets':
49
+ return {'ChangeInfo': {'Id': '/change/C1', 'Status': 'PENDING'}}
50
+ if operation_name == 'GetChange':
51
+ self.get_change_calls += 1
52
+ status = ('INSYNC' if self.clock >= self.insync_after
53
+ else 'PENDING')
54
+ return {'ChangeInfo': {'Id': '/change/C1', 'Status': status}}
55
+ if operation_name == 'ListResourceRecordSets':
56
+ return {'ResourceRecordSets': [RECORD], 'IsTruncated': False}
57
+ raise AssertionError('unexpected operation: ' + operation_name)
58
+
59
+ def sleep(self, secs):
60
+ self.sleeps.append(secs)
61
+ self.clock += secs
62
+
63
+ @property
64
+ def total_wait(self):
65
+ return sum(self.sleeps)
66
+
67
+
68
+ def _provider():
69
+ return AWSCloudProvider({'aws_access_key': 'dummy',
70
+ 'aws_secret_key': 'dummy',
71
+ 'aws_zone_name': 'us-east-1a'})
72
+
73
+
74
+ def _run(sim, fn):
75
+ """Run fn with Route53 stubbed and the waiter's clock simulated."""
76
+ with mock.patch.object(botocore.client.BaseClient, '_make_api_call',
77
+ lambda self, op, params: sim.api(op, params)), \
78
+ mock.patch.object(botocore.waiter.time, 'sleep', sim.sleep):
79
+ return fn()
80
+
81
+
82
+ class AWSDnsWaiterTestCase(unittest.TestCase):
83
+
84
+ def setUp(self):
85
+ self.provider = _provider()
86
+ self.svc = AWSDnsRecordService(self.provider)
87
+ self.zone = AWSDnsZone(self.provider, ZONE)
88
+ self.record = AWSDnsRecord(self.provider, self.zone, RECORD)
89
+
90
+ def test_create_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
91
+ sim = _Route53Sim()
92
+
93
+ _run(sim, lambda: self.svc.create(
94
+ self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
95
+
96
+ self.assertLess(
97
+ sim.total_wait, BOTO_DEFAULT_DELAY,
98
+ "A change that went INSYNC after %ss cost %ss of sleep; the "
99
+ "waiter is still polling at boto3's %ss default"
100
+ % (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
101
+
102
+ def test_delete_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
103
+ sim = _Route53Sim()
104
+
105
+ _run(sim, lambda: self.svc.delete(self.zone, self.record))
106
+
107
+ self.assertLess(
108
+ sim.total_wait, BOTO_DEFAULT_DELAY,
109
+ "A change that went INSYNC after %ss cost %ss of sleep; the "
110
+ "waiter is still polling at boto3's %ss default"
111
+ % (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
112
+
113
+ def test_waiter_polls_until_the_change_is_actually_insync(self):
114
+ """Faster polling must not mean giving up early."""
115
+ sim = _Route53Sim(insync_after=47.0)
116
+
117
+ _run(sim, lambda: self.svc.create(
118
+ self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
119
+
120
+ self.assertGreaterEqual(sim.clock, 47.0,
121
+ "Returned before the change was INSYNC")
122
+ self.assertGreater(sim.get_change_calls, 1)
123
+
124
+ def test_waiter_ceiling_is_still_about_thirty_minutes(self):
125
+ """Polling more often must not shrink how long we are willing to
126
+ wait -- a genuinely slow change should still be given ~30 minutes
127
+ before the waiter gives up."""
128
+ sim = _Route53Sim(insync_after=float('inf'))
129
+
130
+ with self.assertRaises(WaiterError):
131
+ _run(sim, lambda: self.svc.create(
132
+ self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
133
+
134
+ self.assertGreaterEqual(
135
+ sim.total_wait, REQUIRED_CEILING,
136
+ "Waiter gave up after only %ss of simulated waiting"
137
+ % sim.total_wait)
138
+
139
+
140
+ if __name__ == '__main__':
141
+ unittest.main()
@@ -0,0 +1,166 @@
1
+ """
2
+ Unit tests for ``AWSVMTypeService`` catalogue retrieval.
3
+
4
+ AWS has no server-side pagination for VM types, so ``list()`` materialises the
5
+ whole catalogue and hands it to ``ClientPagedResultList`` for client-side
6
+ paging. Fetching that catalogue is expensive: one
7
+ ``DescribeInstanceTypeOfferings`` walk plus a ``DescribeInstanceTypes`` call
8
+ per 100 types (~14 calls for a real region). Re-fetching it for every page
9
+ makes walking the full list quadratic in API calls, so these tests pin the
10
+ catalogue down to a single fetch per zone.
11
+
12
+ Exercised against an in-memory fake connection so they run in CI without
13
+ cloud credentials.
14
+ """
15
+ import unittest
16
+
17
+ from cloudbridge.providers.aws import AWSCloudProvider
18
+
19
+ # Enough types to force several pages at the tests' small result limit, and
20
+ # more than one 100-type DescribeInstanceTypes chunk.
21
+ CATALOGUE_SIZE = 250
22
+ CHUNK_SIZE = 100
23
+ PAGE_SIZE = 5
24
+
25
+
26
+ class _FakeEC2Client:
27
+ """Records every catalogue call made against it."""
28
+
29
+ def __init__(self, types_by_zone):
30
+ self._types_by_zone = types_by_zone
31
+ self.offering_calls = [] # zone per call
32
+ self.describe_type_calls = [] # list of requested type names
33
+
34
+ def describe_instance_type_offerings(self, **kwargs):
35
+ zone = kwargs['Filters'][0]['Values'][0]
36
+ self.offering_calls.append(zone)
37
+ names = self._types_by_zone[zone]
38
+ return {'InstanceTypeOfferings': [{'InstanceType': n} for n in names]}
39
+
40
+ def describe_instance_types(self, **kwargs):
41
+ requested = kwargs['InstanceTypes']
42
+ self.describe_type_calls.append(requested)
43
+ return {'InstanceTypes': [{'InstanceType': n,
44
+ 'CurrentGeneration': True,
45
+ 'VCpuInfo': {'DefaultVCpus': 2}}
46
+ for n in requested]}
47
+
48
+
49
+ class _FakeEC2Conn:
50
+ def __init__(self, client):
51
+ self.meta = type('_Meta', (), {'client': client})()
52
+
53
+
54
+ def _make_provider(zone, types_by_zone):
55
+ provider = AWSCloudProvider({
56
+ 'aws_access_key': 'dummy',
57
+ 'aws_secret_key': 'dummy',
58
+ 'aws_zone_name': zone,
59
+ 'default_result_limit': PAGE_SIZE,
60
+ })
61
+ client = _FakeEC2Client(types_by_zone)
62
+ # ec2_conn is a lazily-populated property backed by this attribute.
63
+ provider._ec2_conn = _FakeEC2Conn(client)
64
+ return provider, client
65
+
66
+
67
+ def _zone_types(prefix, count=CATALOGUE_SIZE):
68
+ return ['{0}.type{1}'.format(prefix, i) for i in range(count)]
69
+
70
+
71
+ class AWSVMTypeCatalogueTestCase(unittest.TestCase):
72
+
73
+ def setUp(self):
74
+ self.zone = 'us-east-1a'
75
+ self.types = {self.zone: _zone_types('a')}
76
+
77
+ def _calls_for_one_catalogue_fetch(self):
78
+ expected_chunks = -(-CATALOGUE_SIZE // CHUNK_SIZE) # ceil div
79
+ return 1, expected_chunks
80
+
81
+ def test_single_list_fetches_catalogue_once(self):
82
+ provider, client = _make_provider(self.zone, self.types)
83
+
84
+ provider.compute.vm_types.list()
85
+
86
+ offerings, chunks = self._calls_for_one_catalogue_fetch()
87
+ self.assertEqual(len(client.offering_calls), offerings)
88
+ self.assertEqual(len(client.describe_type_calls), chunks)
89
+
90
+ def test_repeated_list_calls_reuse_the_catalogue(self):
91
+ provider, client = _make_provider(self.zone, self.types)
92
+
93
+ provider.compute.vm_types.list()
94
+ provider.compute.vm_types.list()
95
+ provider.compute.vm_types.list()
96
+
97
+ offerings, chunks = self._calls_for_one_catalogue_fetch()
98
+ self.assertEqual(
99
+ len(client.offering_calls), offerings,
100
+ "Catalogue offerings should be fetched once and reused, but were "
101
+ "fetched %s times" % len(client.offering_calls))
102
+ self.assertEqual(
103
+ len(client.describe_type_calls), chunks,
104
+ "Instance type details should be fetched once and reused, but "
105
+ "%s calls were made" % len(client.describe_type_calls))
106
+
107
+ def test_paging_through_all_pages_does_not_refetch_catalogue(self):
108
+ """The cost of walking every page must not scale with the page count.
109
+
110
+ This is the access pattern used by the standard-behaviour test helper
111
+ (``check_list``) and it is what made the AWS suite take ~50 minutes.
112
+ """
113
+ provider, client = _make_provider(self.zone, self.types)
114
+
115
+ result = provider.compute.vm_types.list()
116
+ pages = 1
117
+ while result.is_truncated:
118
+ result = provider.compute.vm_types.list(marker=result.marker)
119
+ pages += 1
120
+
121
+ self.assertEqual(pages, -(-CATALOGUE_SIZE // PAGE_SIZE),
122
+ "Expected to walk the whole catalogue")
123
+ offerings, chunks = self._calls_for_one_catalogue_fetch()
124
+ self.assertEqual(
125
+ len(client.offering_calls), offerings,
126
+ "Walking %s pages refetched the offerings %s times"
127
+ % (pages, len(client.offering_calls)))
128
+ self.assertEqual(
129
+ len(client.describe_type_calls), chunks,
130
+ "Walking %s pages made %s DescribeInstanceTypes calls; the "
131
+ "catalogue should be fetched once"
132
+ % (pages, len(client.describe_type_calls)))
133
+
134
+ def test_list_still_returns_the_full_catalogue_contents(self):
135
+ """Caching must not change what callers observe."""
136
+ provider, client = _make_provider(self.zone, self.types)
137
+
138
+ seen = []
139
+ result = provider.compute.vm_types.list()
140
+ seen.extend(t.id for t in result)
141
+ while result.is_truncated:
142
+ result = provider.compute.vm_types.list(marker=result.marker)
143
+ seen.extend(t.id for t in result)
144
+
145
+ self.assertEqual(seen, self.types[self.zone])
146
+
147
+ def test_catalogue_is_keyed_by_zone(self):
148
+ """A provider cloned to another zone must not reuse the first zone's
149
+ catalogue — zones genuinely offer different instance types."""
150
+ other_zone = 'us-east-1b'
151
+ types = {self.zone: _zone_types('a'), other_zone: _zone_types('b')}
152
+ provider, client = _make_provider(self.zone, types)
153
+
154
+ first = [t.id for t in provider.compute.vm_types.list()]
155
+
156
+ provider.config['aws_zone_name'] = other_zone
157
+ provider._zone_name = other_zone
158
+ second = [t.id for t in provider.compute.vm_types.list()]
159
+
160
+ self.assertEqual(first, types[self.zone][:PAGE_SIZE])
161
+ self.assertEqual(second, types[other_zone][:PAGE_SIZE])
162
+ self.assertEqual(client.offering_calls, [self.zone, other_zone])
163
+
164
+
165
+ if __name__ == '__main__':
166
+ unittest.main()
@@ -9,6 +9,7 @@ driver is exercised here directly against in-memory fakes so it has coverage
9
9
  in CI without cloud credentials.
10
10
  """
11
11
  import os
12
+ import shutil
12
13
  import tempfile
13
14
  import threading
14
15
  import unittest
@@ -32,12 +33,15 @@ class _Recorder:
32
33
  self.active = 0
33
34
  self.max_active = 0
34
35
  self.fail_on_offset = None # offset that should raise
36
+ self.on_serve = None # hook called as each range is served
35
37
 
36
38
  def serve_range(self, service, offset, length):
37
39
  with self._lock:
38
40
  self.active += 1
39
41
  self.max_active = max(self.max_active, self.active)
40
42
  try:
43
+ if self.on_serve:
44
+ self.on_serve()
41
45
  if self.fail_on_offset == offset:
42
46
  raise RuntimeError("boom at offset %d" % offset)
43
47
  # Hold briefly so concurrent fetches genuinely overlap.
@@ -222,6 +226,72 @@ class DownloadDriverTestCase(unittest.TestCase):
222
226
  if os.path.exists(path):
223
227
  os.remove(path)
224
228
 
229
+ def test_destination_only_appears_once_complete(self):
230
+ content = bytes(range(256))
231
+ recorder = _Recorder(content)
232
+ driver = self._driver(
233
+ recorder, threshold=1, part_size=16, concurrency=3)
234
+ fd, path = tempfile.mkstemp()
235
+ os.close(fd)
236
+ os.remove(path)
237
+ seen_early = []
238
+ recorder.on_serve = lambda: seen_early.append(os.path.exists(path))
239
+ try:
240
+ driver.download_to_file(path)
241
+ with open(path, 'rb') as f:
242
+ self.assertEqual(f.read(), content)
243
+ finally:
244
+ if os.path.exists(path):
245
+ os.remove(path)
246
+ # A partially written object is never visible at the destination.
247
+ self.assertTrue(seen_early)
248
+ self.assertNotIn(True, seen_early)
249
+
250
+ def test_survives_concurrent_downloader_taking_the_destination(self):
251
+ # Galaxy gives every download of a dataset the same cache .tmp path,
252
+ # so a second download of the same dataset can rename the destination
253
+ # away while this one is still fetching ranges.
254
+ content = bytes(range(256))
255
+ recorder = _Recorder(content)
256
+ driver = self._driver(
257
+ recorder, threshold=1, part_size=16, concurrency=3)
258
+ directory = tempfile.mkdtemp()
259
+ path = os.path.join(directory, 'dataset.dat')
260
+ taken = os.path.join(directory, 'taken.dat')
261
+
262
+ def steal_destination():
263
+ if os.path.exists(path):
264
+ os.replace(path, taken)
265
+
266
+ recorder.on_serve = steal_destination
267
+ try:
268
+ driver.download_to_file(path)
269
+ with open(path, 'rb') as f:
270
+ self.assertEqual(f.read(), content)
271
+ finally:
272
+ shutil.rmtree(directory)
273
+
274
+ def test_failed_download_leaves_an_existing_destination_intact(self):
275
+ content = bytes(range(64))
276
+ recorder = _Recorder(content)
277
+ recorder.fail_on_offset = 16
278
+ driver = self._driver(
279
+ recorder, threshold=1, part_size=16, concurrency=2)
280
+ directory = tempfile.mkdtemp()
281
+ path = os.path.join(directory, 'dataset.dat')
282
+ with open(path, 'wb') as f:
283
+ f.write(b'previously cached')
284
+ try:
285
+ with self.assertRaises(Exception):
286
+ driver.download_to_file(path)
287
+ # The cached copy survives a failed refetch, and no scratch file
288
+ # is left behind next to it.
289
+ with open(path, 'rb') as f:
290
+ self.assertEqual(f.read(), b'previously cached')
291
+ self.assertEqual(os.listdir(directory), ['dataset.dat'])
292
+ finally:
293
+ shutil.rmtree(directory)
294
+
225
295
  def test_part_size_must_be_positive(self):
226
296
  content = bytes(range(16))
227
297
  recorder = _Recorder(content)
File without changes
File without changes
File without changes
File without changes