cloudbridge 4.3.0__tar.gz → 4.4.0__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.
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/PKG-INFO +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/resources.py +85 -43
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/services.py +2 -1
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/resources.py +41 -5
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/helpers.py +55 -1
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/resources.py +18 -7
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/services.py +64 -11
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/resources.py +18 -29
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/resources.py +25 -8
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/resources.py +7 -3
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/PKG-INFO +1 -1
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/SOURCES.txt +5 -0
- cloudbridge-4.4.0/tests/test_aws_dns_waiters.py +141 -0
- cloudbridge-4.4.0/tests/test_aws_pagination.py +119 -0
- cloudbridge-4.4.0/tests/test_aws_vm_types.py +166 -0
- cloudbridge-4.4.0/tests/test_default_network.py +68 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_download_driver.py +71 -1
- cloudbridge-4.4.0/tests/test_iter_content.py +293 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_object_store_service.py +108 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/LICENSE +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/README.rst +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/azure_client.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/provider.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/services.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/requires.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/pyproject.toml +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/setup.cfg +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_cloud_helpers.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_image_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_interface.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_multipart_driver.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_network_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_region_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_security_service.py +0 -0
- {cloudbridge-4.3.0 → cloudbridge-4.4.0}/tests/test_vm_types_service.py +0 -0
|
@@ -8,7 +8,7 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import queue
|
|
10
10
|
import re
|
|
11
|
-
import
|
|
11
|
+
import threading
|
|
12
12
|
import time
|
|
13
13
|
import uuid
|
|
14
14
|
from concurrent.futures import FIRST_COMPLETED
|
|
@@ -70,8 +70,6 @@ from cloudbridge.interfaces.resources import VolumeState
|
|
|
70
70
|
from . import helpers as cb_helpers
|
|
71
71
|
|
|
72
72
|
if TYPE_CHECKING:
|
|
73
|
-
from _typeshed import SupportsRead
|
|
74
|
-
|
|
75
73
|
from cloudbridge.base.provider import BaseCloudProvider
|
|
76
74
|
from cloudbridge.base.services import BaseStorageService
|
|
77
75
|
from cloudbridge.interfaces.services import BucketObjectService
|
|
@@ -839,6 +837,15 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
839
837
|
# Number of parts uploaded in parallel by the transparent multipart path.
|
|
840
838
|
CB_MULTIPART_MAX_CONCURRENCY = int(os.environ.get(
|
|
841
839
|
'CB_MULTIPART_MAX_CONCURRENCY', 5))
|
|
840
|
+
# Size of each chunk yielded by the single-stream read path
|
|
841
|
+
# (``iter_content``/``save_content``). Sits at the knee of the
|
|
842
|
+
# throughput curve: reading an HTTP body at 1 MiB is ~12x cheaper per
|
|
843
|
+
# byte than at 4 KiB, while 4 MiB and above measure the same as 1 MiB
|
|
844
|
+
# and cost proportionally more memory per concurrent stream. Unrelated
|
|
845
|
+
# to CB_MULTIPART_PART_SIZE, which sizes the parts of a *parallel*
|
|
846
|
+
# transfer rather than the chunks of a sequential read.
|
|
847
|
+
CB_ITER_CHUNK_SIZE = int(os.environ.get(
|
|
848
|
+
'CB_ITER_CHUNK_SIZE', 1024 * 1024)) # 1 MiB
|
|
842
849
|
|
|
843
850
|
def __init__(self, provider: CloudProvider) -> None:
|
|
844
851
|
super(BaseBucketObject, self).__init__(provider)
|
|
@@ -877,23 +884,54 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
877
884
|
"in: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMeta"
|
|
878
885
|
"data.html#object-key-guidelines" % name)
|
|
879
886
|
|
|
880
|
-
def save_content(self, target_stream: IO[bytes]
|
|
881
|
-
|
|
882
|
-
#
|
|
883
|
-
#
|
|
884
|
-
shutil.copyfileobj(
|
|
885
|
-
|
|
887
|
+
def save_content(self, target_stream: IO[bytes],
|
|
888
|
+
chunk_size: int | None = None) -> None:
|
|
889
|
+
# Written in terms of iter_content so that the interface's promise -
|
|
890
|
+
# an Iterable[bytes] - is all a provider has to deliver. Copying via
|
|
891
|
+
# shutil.copyfileobj would additionally require a .read(), which not
|
|
892
|
+
# every provider's return value has.
|
|
893
|
+
for chunk in self.iter_content(chunk_size=chunk_size):
|
|
894
|
+
target_stream.write(chunk)
|
|
886
895
|
|
|
887
896
|
def download_to_file(self, path: str,
|
|
888
897
|
config: TransferConfig | None = None) -> None:
|
|
898
|
+
# Assemble the object in a private file alongside the destination and
|
|
899
|
+
# rename it into place once complete, so ``path`` only ever holds a
|
|
900
|
+
# whole object. Callers commonly download every copy of an object to
|
|
901
|
+
# one well-known path (a cache entry, say), so writing in place would
|
|
902
|
+
# let concurrent downloads truncate each other's file - or rename it
|
|
903
|
+
# away mid-transfer - and would destroy a previously downloaded copy
|
|
904
|
+
# when a transfer fails.
|
|
905
|
+
part_path = f"{path}.{uuid.uuid4().hex}.cbpart"
|
|
906
|
+
try:
|
|
907
|
+
self._download_to_path(part_path, config)
|
|
908
|
+
os.replace(part_path, path)
|
|
909
|
+
except BaseException:
|
|
910
|
+
try:
|
|
911
|
+
os.remove(part_path)
|
|
912
|
+
except OSError:
|
|
913
|
+
pass
|
|
914
|
+
raise
|
|
915
|
+
|
|
916
|
+
def _download_to_path(self, path: str,
|
|
917
|
+
config: TransferConfig | None = None) -> None:
|
|
918
|
+
"""
|
|
919
|
+
Write this object's content to ``path``, which the caller owns.
|
|
920
|
+
|
|
921
|
+
Providers with an efficient, thread-safe native downloader (e.g. AWS
|
|
922
|
+
via boto3's ``download_file``, Azure via ``download_blob``) override
|
|
923
|
+
this to use it; the default implementation streams small objects and
|
|
924
|
+
fetches larger ones as parallel ranged reads.
|
|
925
|
+
"""
|
|
889
926
|
size = self.size
|
|
890
927
|
if size <= self._multipart_threshold(config):
|
|
891
928
|
with open(path, 'wb') as f:
|
|
892
929
|
self.save_content(f)
|
|
893
930
|
return
|
|
894
|
-
|
|
931
|
+
with open(path, 'w+b') as f:
|
|
932
|
+
self._download_ranged(f, size, config)
|
|
895
933
|
|
|
896
|
-
def _download_ranged(self,
|
|
934
|
+
def _download_ranged(self, target: IO[bytes], size: int,
|
|
897
935
|
config: TransferConfig | None = None) -> None:
|
|
898
936
|
"""
|
|
899
937
|
Fetch the object as ranged reads across a bounded thread pool,
|
|
@@ -902,40 +940,26 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
902
940
|
To stay safe even on providers whose SDK client/connection is not
|
|
903
941
|
thread-safe, each worker reads through its own cloned provider (see
|
|
904
942
|
:meth:`.CloudProvider.clone`), so no provider state is shared between
|
|
905
|
-
threads. Memory is bounded to ~concurrency * part_size.
|
|
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.
|
|
943
|
+
threads. Memory is bounded to ~concurrency * part_size.
|
|
911
944
|
"""
|
|
912
945
|
part_size = self._multipart_part_size(config)
|
|
913
946
|
if part_size < 1:
|
|
914
947
|
raise InvalidValueException('part_size', part_size)
|
|
915
948
|
concurrency = max(1, self._multipart_max_concurrency(config))
|
|
949
|
+
target.truncate(size)
|
|
916
950
|
ranges = [(offset, min(part_size, size - offset))
|
|
917
951
|
for offset in range(0, size, part_size)]
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
bucket_objects
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
|
952
|
+
if concurrency == 1:
|
|
953
|
+
bucket_objects = self._bucket_objects
|
|
954
|
+
for offset, length in ranges:
|
|
955
|
+
target.seek(offset)
|
|
956
|
+
target.write(bucket_objects.download_range(
|
|
957
|
+
self.bucket, self.name, offset, length))
|
|
958
|
+
else:
|
|
959
|
+
self._download_ranges_concurrently(target, ranges, concurrency)
|
|
936
960
|
|
|
937
961
|
def _download_ranges_concurrently(
|
|
938
|
-
self,
|
|
962
|
+
self, target: IO[bytes], ranges: list[tuple[int, int]],
|
|
939
963
|
concurrency: int) -> None:
|
|
940
964
|
# A pool of cloned bucket-object services, one per worker, so each
|
|
941
965
|
# thread touches an isolated provider/connection.
|
|
@@ -947,6 +971,7 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
947
971
|
|
|
948
972
|
bucket = self.bucket
|
|
949
973
|
name = self.name
|
|
974
|
+
write_lock = threading.Lock()
|
|
950
975
|
|
|
951
976
|
def fetch_one(offset: int, length: int) -> None:
|
|
952
977
|
service = clones.get()
|
|
@@ -954,13 +979,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
954
979
|
data = service.download_range(bucket, name, offset, length)
|
|
955
980
|
finally:
|
|
956
981
|
clones.put(service)
|
|
957
|
-
#
|
|
958
|
-
#
|
|
959
|
-
#
|
|
960
|
-
#
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
982
|
+
# Ranges are fetched in parallel but written through the one
|
|
983
|
+
# handle the caller opened, so a range can never be written to a
|
|
984
|
+
# file that has since been replaced. Serializing the writes costs
|
|
985
|
+
# little next to the fetches, and the data is released as soon as
|
|
986
|
+
# it is written, bounding memory to ~concurrency * part_size.
|
|
987
|
+
with write_lock:
|
|
988
|
+
target.seek(offset)
|
|
989
|
+
target.write(data)
|
|
964
990
|
|
|
965
991
|
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
966
992
|
futures = [executor.submit(fetch_one, offset, length)
|
|
@@ -990,6 +1016,22 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
990
1016
|
return int(self._provider._get_config_value(
|
|
991
1017
|
'multipart_max_concurrency', self.CB_MULTIPART_MAX_CONCURRENCY))
|
|
992
1018
|
|
|
1019
|
+
def _iter_chunk_size(self, chunk_size: int | None = None) -> int:
|
|
1020
|
+
"""
|
|
1021
|
+
Resolve the chunk size for a single-stream read: an explicit
|
|
1022
|
+
``chunk_size``, else the provider/global config, else the class
|
|
1023
|
+
default. Providers call this at the top of ``iter_content`` so the
|
|
1024
|
+
value is validated before any request is issued.
|
|
1025
|
+
"""
|
|
1026
|
+
if chunk_size is None:
|
|
1027
|
+
chunk_size = int(self._provider._get_config_value(
|
|
1028
|
+
'iter_chunk_size', self.CB_ITER_CHUNK_SIZE))
|
|
1029
|
+
else:
|
|
1030
|
+
chunk_size = int(chunk_size)
|
|
1031
|
+
if chunk_size <= 0:
|
|
1032
|
+
raise InvalidValueException('iter_chunk_size', chunk_size)
|
|
1033
|
+
return chunk_size
|
|
1034
|
+
|
|
993
1035
|
@staticmethod
|
|
994
1036
|
def _data_size(data: str | bytes | IO[bytes]) -> int | None:
|
|
995
1037
|
"""
|
|
@@ -320,7 +320,8 @@ class BaseNetworkService(
|
|
|
320
320
|
log.info("Creating a CloudBridge-default network labeled %s",
|
|
321
321
|
BaseNetwork.CB_DEFAULT_NETWORK_LABEL)
|
|
322
322
|
return self.provider.networking.networks.create(
|
|
323
|
-
BaseNetwork.CB_DEFAULT_NETWORK_LABEL,
|
|
323
|
+
BaseNetwork.CB_DEFAULT_NETWORK_LABEL,
|
|
324
|
+
BaseNetwork.CB_DEFAULT_IPV4RANGE)
|
|
324
325
|
|
|
325
326
|
@dispatch(event="provider.networking.networks.find",
|
|
326
327
|
priority=BaseCloudService.STANDARD_EVENT_PRIORITY)
|
|
@@ -2357,20 +2357,52 @@ class BucketObject(CloudResource):
|
|
|
2357
2357
|
pass
|
|
2358
2358
|
|
|
2359
2359
|
@abstractmethod
|
|
2360
|
-
def iter_content(self) -> Iterable[bytes]:
|
|
2360
|
+
def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
|
|
2361
2361
|
"""
|
|
2362
|
-
Returns this object's content as an iterable.
|
|
2362
|
+
Returns this object's content as an iterable of byte chunks.
|
|
2363
|
+
|
|
2364
|
+
The object is streamed rather than held in memory, so this is safe
|
|
2365
|
+
for objects of any size. Chunks are sized by ``chunk_size``, never by
|
|
2366
|
+
the content itself - binary data is never split on newlines - and
|
|
2367
|
+
only the final chunk may be shorter.
|
|
2368
|
+
|
|
2369
|
+
``chunk_size`` trades per-chunk overhead against memory and latency.
|
|
2370
|
+
Each chunk costs a read from the provider plus whatever the caller
|
|
2371
|
+
does per chunk, so small values are expensive over a large object;
|
|
2372
|
+
conversely a chunk is buffered in full before it is yielded, so large
|
|
2373
|
+
values cost memory per concurrent stream and delay the first chunk.
|
|
2374
|
+
The default suits most callers.
|
|
2375
|
+
|
|
2376
|
+
:type chunk_size: ``int``
|
|
2377
|
+
:param chunk_size: Maximum size in bytes of each chunk yielded. If
|
|
2378
|
+
``None``, falls back to the provider/global configuration
|
|
2379
|
+
(``iter_chunk_size`` / the ``CB_ITER_CHUNK_SIZE`` setting,
|
|
2380
|
+
1 MiB by default). Must be positive.
|
|
2363
2381
|
|
|
2364
2382
|
:rtype: Iterable
|
|
2365
2383
|
:return: An iterable of the file contents
|
|
2366
2384
|
|
|
2385
|
+
:raise: ``InvalidValueException`` if ``chunk_size`` is not positive.
|
|
2367
2386
|
"""
|
|
2368
2387
|
pass
|
|
2369
2388
|
|
|
2370
2389
|
@abstractmethod
|
|
2371
|
-
def save_content(self, target_stream: IO[bytes]
|
|
2390
|
+
def save_content(self, target_stream: IO[bytes],
|
|
2391
|
+
chunk_size: int | None = None) -> None:
|
|
2372
2392
|
"""
|
|
2373
2393
|
Save this object and write its contents to the ``target_stream``.
|
|
2394
|
+
|
|
2395
|
+
The object is streamed through ``iter_content``, so ``chunk_size``
|
|
2396
|
+
means what it does there.
|
|
2397
|
+
|
|
2398
|
+
:type target_stream: ``IO[bytes]``
|
|
2399
|
+
:param target_stream: A writable binary stream to write to.
|
|
2400
|
+
|
|
2401
|
+
:type chunk_size: ``int``
|
|
2402
|
+
:param chunk_size: Maximum size in bytes of each chunk read from the
|
|
2403
|
+
provider. See :meth:`.iter_content`.
|
|
2404
|
+
|
|
2405
|
+
:raise: ``InvalidValueException`` if ``chunk_size`` is not positive.
|
|
2374
2406
|
"""
|
|
2375
2407
|
pass
|
|
2376
2408
|
|
|
@@ -2388,8 +2420,12 @@ class BucketObject(CloudResource):
|
|
|
2388
2420
|
remain single-stream alternatives for arbitrary target streams.
|
|
2389
2421
|
|
|
2390
2422
|
:type path: ``str``
|
|
2391
|
-
:param path: Local path to write the object's content to.
|
|
2392
|
-
|
|
2423
|
+
:param path: Local path to write the object's content to. The object
|
|
2424
|
+
is assembled out of the way and moved into place once complete,
|
|
2425
|
+
so ``path`` never holds a partial object: an existing file is
|
|
2426
|
+
replaced atomically, and a failed transfer leaves it untouched.
|
|
2427
|
+
Concurrent downloads to one path are therefore safe, with the
|
|
2428
|
+
last to complete winning.
|
|
2393
2429
|
|
|
2394
2430
|
:type config: :class:`.TransferConfig`
|
|
2395
2431
|
:param config: Optional per-call transfer tuning (threshold, part
|
|
@@ -25,6 +25,18 @@ log = logging.getLogger(__name__)
|
|
|
25
25
|
|
|
26
26
|
T = TypeVar("T")
|
|
27
27
|
|
|
28
|
+
# How many records to request per call while satisfying a list method,
|
|
29
|
+
# overridable per provider with the ``aws_page_size`` config value. This is a
|
|
30
|
+
# transport setting and is deliberately not ``default_result_limit``, which
|
|
31
|
+
# bounds how many results the caller receives - see
|
|
32
|
+
# BotoGenericService._page_size for why conflating them is expensive.
|
|
33
|
+
#
|
|
34
|
+
# AWS-specific because it only means anything where the provider walks pages
|
|
35
|
+
# itself. GCP, Azure and OpenStack each return a single page plus a
|
|
36
|
+
# continuation token and let the caller drive, so there is nothing for a page
|
|
37
|
+
# size to amplify; boto3's paginator is the odd one out.
|
|
38
|
+
DEFAULT_PAGE_SIZE = 500
|
|
39
|
+
|
|
28
40
|
|
|
29
41
|
def trim_empty_params(params_dict: dict[str, Any]) -> dict[str, Any]:
|
|
30
42
|
"""
|
|
@@ -153,6 +165,46 @@ class BotoGenericService(object):
|
|
|
153
165
|
else:
|
|
154
166
|
return None
|
|
155
167
|
|
|
168
|
+
def _page_size(self, client: Any, list_op: str) -> int:
|
|
169
|
+
"""
|
|
170
|
+
Transport page size for a paginated call.
|
|
171
|
+
|
|
172
|
+
``PageSize`` is how many items the service returns per request;
|
|
173
|
+
``MaxItems`` is how many the caller asked for. Using the caller's
|
|
174
|
+
limit for both makes a small limit walk a large scan in tiny
|
|
175
|
+
increments: a filtered ``describe_images`` that takes 10.0s at a page
|
|
176
|
+
size of 1000 takes 977.6s at 5, for the same single result. Ask for a
|
|
177
|
+
full page regardless of the limit and let ``MaxItems`` do the
|
|
178
|
+
bounding - at worst one page more data is fetched than was wanted,
|
|
179
|
+
and a limit larger than a page is served over several requests.
|
|
180
|
+
|
|
181
|
+
The size comes from the ``aws_page_size`` config value so it can be
|
|
182
|
+
tuned per provider, then is clamped to whatever the service model
|
|
183
|
+
declares for this operation. EC2's bounds differ per call -
|
|
184
|
+
``DescribeRouteTables`` permits 100 where most permit 1000, and
|
|
185
|
+
several require at least 5 - and falling outside them is a hard
|
|
186
|
+
``InvalidParameterValue`` rather than a clamp, so neither the default
|
|
187
|
+
nor a configured value is passed through unchecked.
|
|
188
|
+
"""
|
|
189
|
+
page_size = int(self.provider._get_config_value(
|
|
190
|
+
'aws_page_size', DEFAULT_PAGE_SIZE))
|
|
191
|
+
api_name = client.meta.method_to_api_mapping.get(list_op)
|
|
192
|
+
if not api_name:
|
|
193
|
+
return page_size
|
|
194
|
+
input_shape = client.meta.service_model.operation_model(
|
|
195
|
+
api_name).input_shape
|
|
196
|
+
max_results = input_shape.members.get('MaxResults') if input_shape \
|
|
197
|
+
else None
|
|
198
|
+
# Not every operation declares bounds, even where the documentation
|
|
199
|
+
# gives them; the default is within all of the undeclared ones.
|
|
200
|
+
bounds = max_results.metadata if max_results else {}
|
|
201
|
+
ceiling, floor = bounds.get('max'), bounds.get('min')
|
|
202
|
+
if ceiling:
|
|
203
|
+
page_size = min(page_size, ceiling)
|
|
204
|
+
if floor:
|
|
205
|
+
page_size = max(page_size, floor)
|
|
206
|
+
return page_size
|
|
207
|
+
|
|
156
208
|
def _get_list_operation(self) -> str:
|
|
157
209
|
"""
|
|
158
210
|
This function discovers the list operation for a particular resource
|
|
@@ -195,7 +247,9 @@ class BotoGenericService(object):
|
|
|
195
247
|
paginator = client.get_paginator(list_op)
|
|
196
248
|
PaginationConfig: dict[str, Any] = {}
|
|
197
249
|
if limit:
|
|
198
|
-
PaginationConfig = {
|
|
250
|
+
PaginationConfig = {
|
|
251
|
+
'MaxItems': limit,
|
|
252
|
+
'PageSize': self._page_size(client, list_op)}
|
|
199
253
|
|
|
200
254
|
if marker:
|
|
201
255
|
PaginationConfig.update({'StartingToken': marker})
|
|
@@ -861,14 +861,21 @@ class AWSVMFirewallRule(BaseVMFirewallRule):
|
|
|
861
861
|
|
|
862
862
|
class AWSBucketObject(BaseBucketObject):
|
|
863
863
|
class BucketObjIterator():
|
|
864
|
-
|
|
864
|
+
"""
|
|
865
|
+
Chunked reader over a boto3 ``StreamingBody``.
|
|
866
|
+
|
|
867
|
+
Also exposes ``read``/``close`` so the value handed back by
|
|
868
|
+
``iter_content`` stays usable as a file-like object, as it has been
|
|
869
|
+
since this replaced boto2's ``Key``.
|
|
870
|
+
"""
|
|
865
871
|
|
|
866
|
-
def __init__(self, body: Any) -> None:
|
|
872
|
+
def __init__(self, body: Any, chunk_size: int) -> None:
|
|
867
873
|
self.body = body
|
|
874
|
+
self.chunk_size = chunk_size
|
|
868
875
|
|
|
869
876
|
def __iter__(self) -> Iterator[bytes]:
|
|
870
877
|
while True:
|
|
871
|
-
data = self.read(self.
|
|
878
|
+
data = self.read(self.chunk_size)
|
|
872
879
|
if data:
|
|
873
880
|
yield data
|
|
874
881
|
else:
|
|
@@ -910,8 +917,12 @@ class AWSBucketObject(BaseBucketObject):
|
|
|
910
917
|
cast("AWSCloudProvider", self._provider).s3_conn
|
|
911
918
|
.Bucket(self._obj.bucket_name))
|
|
912
919
|
|
|
913
|
-
def iter_content(self) -> Iterable[bytes]:
|
|
914
|
-
|
|
920
|
+
def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
|
|
921
|
+
# Resolve (and validate) the chunk size before the GET, so a bad
|
|
922
|
+
# value does not leave an unread response body behind.
|
|
923
|
+
chunk_size = self._iter_chunk_size(chunk_size)
|
|
924
|
+
return self.BucketObjIterator(
|
|
925
|
+
self._obj.get().get('Body'), chunk_size)
|
|
915
926
|
|
|
916
927
|
def _upload_single_shot(self,
|
|
917
928
|
data: str | bytes | IO[bytes]) -> BucketObject:
|
|
@@ -944,8 +955,8 @@ class AWSBucketObject(BaseBucketObject):
|
|
|
944
955
|
self._obj.upload_file(path, Config=transfer_config)
|
|
945
956
|
return self
|
|
946
957
|
|
|
947
|
-
def
|
|
948
|
-
|
|
958
|
+
def _download_to_path(self, path: str,
|
|
959
|
+
config: TransferConfig | None = None) -> None:
|
|
949
960
|
# boto3's TransferManager downloads large objects as parallel ranged
|
|
950
961
|
# GETs with a thread-safe client, so the transparent ranged path
|
|
951
962
|
# delegates to it rather than CloudBridge's generic clone-pool driver.
|
|
@@ -806,8 +806,22 @@ class AWSImageService(BaseImageService):
|
|
|
806
806
|
log.debug("Searching for AWS Image Service %s", label)
|
|
807
807
|
obj_list.extend(
|
|
808
808
|
self.svc.find(filters={'name': label}, **extra_args))
|
|
809
|
+
# A tag filter can only ever match images in the calling account.
|
|
810
|
+
# AMI tags are not visible across accounts, so an image owned by
|
|
811
|
+
# anyone else can never satisfy tag:Name however it is tagged -
|
|
812
|
+
# asking for every image carrying any visible tag, unscoped across
|
|
813
|
+
# a whole region, returns only this account's. Leaving Owners off
|
|
814
|
+
# therefore does not widen what the search can find; it only makes
|
|
815
|
+
# EC2 evaluate the filter against every public image in the
|
|
816
|
+
# region. Measured in ap-southeast-1, identical single-image
|
|
817
|
+
# results in 10.0s unscoped against 0.1s scoped.
|
|
818
|
+
#
|
|
819
|
+
# An explicit owners argument still wins, so a caller can ask for
|
|
820
|
+
# someone else's images and get the same (empty) answer as before.
|
|
821
|
+
tag_args = dict(extra_args)
|
|
822
|
+
tag_args.setdefault('Owners', ['self'])
|
|
809
823
|
obj_list.extend(
|
|
810
|
-
self.svc.find(filters={'tag:Name': label}, **
|
|
824
|
+
self.svc.find(filters={'tag:Name': label}, **tag_args))
|
|
811
825
|
return ClientPagedResultList(self.provider, obj_list)
|
|
812
826
|
|
|
813
827
|
# Intentionally extends the base list() with a leading filter_by_owner
|
|
@@ -1023,6 +1037,9 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1023
1037
|
|
|
1024
1038
|
def __init__(self, provider: CloudProvider) -> None:
|
|
1025
1039
|
super(AWSVMTypeService, self).__init__(provider)
|
|
1040
|
+
# Raw instance type dicts, keyed by availability zone. See
|
|
1041
|
+
# _get_catalogue for why this is memoised.
|
|
1042
|
+
self._catalogue: dict[str | None, list[dict[str, Any]]] = {}
|
|
1026
1043
|
|
|
1027
1044
|
@dispatch(event="provider.compute.vm_types.get",
|
|
1028
1045
|
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
@@ -1039,15 +1056,12 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1039
1056
|
else:
|
|
1040
1057
|
raise e
|
|
1041
1058
|
|
|
1042
|
-
|
|
1043
|
-
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
1044
|
-
def list(self, limit: int | None = None,
|
|
1045
|
-
marker: str | None = None) -> ResultList[VMType]:
|
|
1059
|
+
def _fetch_catalogue(self, zone: str | None) -> list[dict[str, Any]]:
|
|
1046
1060
|
client = cast("AWSCloudProvider", self.provider).ec2_conn.meta.client
|
|
1047
1061
|
vmt_list_resp = client.describe_instance_type_offerings(
|
|
1048
1062
|
LocationType='availability-zone',
|
|
1049
1063
|
Filters=[{'Name': 'location',
|
|
1050
|
-
'Values': [
|
|
1064
|
+
'Values': [zone]}],
|
|
1051
1065
|
# MaxResults is set to max value (1000)
|
|
1052
1066
|
# and client-side pagination is used
|
|
1053
1067
|
**trim_empty_params({'MaxResults': 1000, 'NextToken': None}))
|
|
@@ -1056,7 +1070,7 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1056
1070
|
vmt_list_resp = client.describe_instance_type_offerings(
|
|
1057
1071
|
LocationType='availability-zone',
|
|
1058
1072
|
Filters=[{'Name': 'location',
|
|
1059
|
-
'Values': [
|
|
1073
|
+
'Values': [zone]}],
|
|
1060
1074
|
**trim_empty_params(
|
|
1061
1075
|
{'MaxResults': 1000,
|
|
1062
1076
|
'NextToken': vmt_list_resp.get("NextToken")}))
|
|
@@ -1067,12 +1081,41 @@ class AWSVMTypeService(BaseVMTypeService):
|
|
|
1067
1081
|
# describe_instance_types call can get at most 100 types at once
|
|
1068
1082
|
chunks = [vmt_list_names[x:x + 100]
|
|
1069
1083
|
for x in range(0, len(vmt_list_names), 100)]
|
|
1070
|
-
raw_types = []
|
|
1084
|
+
raw_types: list[dict[str, Any]] = []
|
|
1071
1085
|
for chunk in chunks:
|
|
1072
1086
|
raw_chunk = client.describe_instance_types(
|
|
1073
1087
|
InstanceTypes=chunk).get('InstanceTypes')
|
|
1074
1088
|
raw_types.extend(raw_chunk)
|
|
1075
|
-
|
|
1089
|
+
return raw_types
|
|
1090
|
+
|
|
1091
|
+
def _get_catalogue(self) -> list[dict[str, Any]]:
|
|
1092
|
+
"""
|
|
1093
|
+
Return the raw instance type catalogue for the provider's zone,
|
|
1094
|
+
fetching it at most once per zone.
|
|
1095
|
+
|
|
1096
|
+
EC2 has no server-side paging for instance types, so ``list()`` must
|
|
1097
|
+
materialise the whole catalogue and page it client-side. Fetching it
|
|
1098
|
+
costs one ``DescribeInstanceTypeOfferings`` walk plus one
|
|
1099
|
+
``DescribeInstanceTypes`` call per 100 types — around 14 calls for a
|
|
1100
|
+
real region. Without memoisation every page of a ``list(marker=...)``
|
|
1101
|
+
walk repeats all of that, making a full walk quadratic in API calls:
|
|
1102
|
+
1343 types at a result limit of 5 costs ~4300 calls instead of ~14.
|
|
1103
|
+
|
|
1104
|
+
The catalogue is static for the lifetime of a provider, so it is held
|
|
1105
|
+
per zone (a provider may be cloned to another zone, which genuinely
|
|
1106
|
+
offers a different set of types).
|
|
1107
|
+
"""
|
|
1108
|
+
zone = self.provider.zone_name
|
|
1109
|
+
if zone not in self._catalogue:
|
|
1110
|
+
self._catalogue[zone] = self._fetch_catalogue(zone)
|
|
1111
|
+
return self._catalogue[zone]
|
|
1112
|
+
|
|
1113
|
+
@dispatch(event="provider.compute.vm_types.list",
|
|
1114
|
+
priority=BaseVMTypeService.STANDARD_EVENT_PRIORITY)
|
|
1115
|
+
def list(self, limit: int | None = None,
|
|
1116
|
+
marker: str | None = None) -> ResultList[VMType]:
|
|
1117
|
+
cb_types = [AWSVMType(cast("AWSCloudProvider", self.provider), t)
|
|
1118
|
+
for t in self._get_catalogue()]
|
|
1076
1119
|
return ClientPagedResultList(self.provider, cb_types,
|
|
1077
1120
|
limit=limit, marker=marker)
|
|
1078
1121
|
|
|
@@ -1674,6 +1717,14 @@ class AWSDnsZoneService(BaseDnsZoneService):
|
|
|
1674
1717
|
client.delete_hosted_zone(Id=dns_zone.aws_id)
|
|
1675
1718
|
|
|
1676
1719
|
|
|
1720
|
+
# Route53 reports record changes INSYNC within seconds, but boto3's
|
|
1721
|
+
# resource_record_sets_changed waiter polls every 30s by default, so every
|
|
1722
|
+
# record change costs a full 30s of sleep no matter how fast it propagated.
|
|
1723
|
+
# Poll often enough that the granularity stops dominating, while keeping the
|
|
1724
|
+
# same ~30 minute ceiling for changes that genuinely are slow.
|
|
1725
|
+
DNS_CHANGE_WAITER_CONFIG = {'Delay': 5, 'MaxAttempts': 360}
|
|
1726
|
+
|
|
1727
|
+
|
|
1677
1728
|
class AWSDnsRecordService(BaseDnsRecordService):
|
|
1678
1729
|
|
|
1679
1730
|
def __init__(self, provider: CloudProvider) -> None:
|
|
@@ -1765,7 +1816,8 @@ class AWSDnsRecordService(BaseDnsRecordService):
|
|
|
1765
1816
|
# waiting, this is skipped for mock tests.
|
|
1766
1817
|
if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
|
|
1767
1818
|
waiter = client.get_waiter('resource_record_sets_changed')
|
|
1768
|
-
waiter.wait(Id=response.get('ChangeInfo').get('Id')
|
|
1819
|
+
waiter.wait(Id=response.get('ChangeInfo').get('Id'),
|
|
1820
|
+
WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
|
|
1769
1821
|
return cast(DnsRecord, self.get(dns_zone, name + ":" + type))
|
|
1770
1822
|
|
|
1771
1823
|
def delete(self, dns_zone: DnsZone | str,
|
|
@@ -1793,4 +1845,5 @@ class AWSDnsRecordService(BaseDnsRecordService):
|
|
|
1793
1845
|
# waiting, this is skipped for mock tests.
|
|
1794
1846
|
if not cast("AWSCloudProvider", self.provider).PROVIDER_ID == 'mock':
|
|
1795
1847
|
waiter = client.get_waiter('resource_record_sets_changed')
|
|
1796
|
-
waiter.wait(Id=response.get('ChangeInfo').get('Id')
|
|
1848
|
+
waiter.wait(Id=response.get('ChangeInfo').get('Id'),
|
|
1849
|
+
WaiterConfig=DNS_CHANGE_WAITER_CONFIG)
|
|
@@ -4,7 +4,6 @@ DataTypes used by this provider
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import collections
|
|
7
|
-
import io
|
|
8
7
|
import logging
|
|
9
8
|
from datetime import datetime
|
|
10
9
|
from typing import Any
|
|
@@ -245,37 +244,27 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
245
244
|
"""
|
|
246
245
|
return self._blob_properties.last_modified.strftime("%Y-%m-%dT%H:%M:%S.%f")
|
|
247
246
|
|
|
248
|
-
def iter_content(self) -> Iterable[bytes]:
|
|
247
|
+
def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
|
|
249
248
|
"""
|
|
250
|
-
Returns this object's content as an
|
|
251
|
-
iterable stream.
|
|
249
|
+
Returns this object's content as an iterable of byte chunks.
|
|
252
250
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def readinto(self, b: Any) -> int:
|
|
263
|
-
try:
|
|
264
|
-
buffer_length = len(b) # We're supposed to return at most this much
|
|
265
|
-
chunk = self.leftover or next(iterable)
|
|
266
|
-
output, self.leftover = chunk[:buffer_length], chunk[buffer_length:]
|
|
267
|
-
b[:len(output)] = output
|
|
268
|
-
return len(output)
|
|
269
|
-
except StopIteration:
|
|
270
|
-
return 0 # indicate EOF
|
|
271
|
-
|
|
272
|
-
return IterStream()
|
|
251
|
+
chunk_size = self._iter_chunk_size(chunk_size)
|
|
252
|
+
# The downloader buffers the service's own chunks (max_chunk_get_size,
|
|
253
|
+
# 4 MiB by default) and read() serves chunk_size slices out of them
|
|
254
|
+
# over a single connection, so the caller's chunk size is independent
|
|
255
|
+
# of the service's. Previously this returned a RawIOBase wrapper,
|
|
256
|
+
# which iterates by *line* rather than by size - splitting binary
|
|
257
|
+
# content on b"\n", and buffering a newline-free blob whole.
|
|
258
|
+
downloader = self._blob_client.download_blob()
|
|
273
259
|
|
|
274
260
|
def blob_iterator() -> Iterator[bytes]:
|
|
275
|
-
|
|
276
|
-
|
|
261
|
+
while True:
|
|
262
|
+
data = downloader.read(chunk_size)
|
|
263
|
+
if not data:
|
|
264
|
+
break
|
|
265
|
+
yield data
|
|
277
266
|
|
|
278
|
-
return
|
|
267
|
+
return blob_iterator()
|
|
279
268
|
|
|
280
269
|
@property
|
|
281
270
|
def bucket(self) -> AzureBucket:
|
|
@@ -302,8 +291,8 @@ class AzureBucketObject(BaseBucketObject):
|
|
|
302
291
|
max_concurrency=self._multipart_max_concurrency(config))
|
|
303
292
|
return self
|
|
304
293
|
|
|
305
|
-
def
|
|
306
|
-
|
|
294
|
+
def _download_to_path(self, path: str,
|
|
295
|
+
config: TransferConfig | None = None) -> None:
|
|
307
296
|
# azure-storage-blob's downloader fetches block ranges concurrently
|
|
308
297
|
# with a thread-safe client, so delegate to it rather than
|
|
309
298
|
# CloudBridge's generic clone-pool driver.
|