cloudbridge 4.3.1__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.1 → cloudbridge-4.4.0}/PKG-INFO +1 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/resources.py +33 -9
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/services.py +2 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/resources.py +35 -3
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/helpers.py +55 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/resources.py +16 -5
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/services.py +15 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/resources.py +16 -27
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/resources.py +25 -8
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/resources.py +7 -3
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/PKG-INFO +1 -1
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/SOURCES.txt +3 -0
- cloudbridge-4.4.0/tests/test_aws_pagination.py +119 -0
- cloudbridge-4.4.0/tests/test_default_network.py +68 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_download_driver.py +1 -1
- cloudbridge-4.4.0/tests/test_iter_content.py +293 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_object_store_service.py +108 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/LICENSE +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/README.rst +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/services.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/azure_client.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/services.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/services.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/provider.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/services.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/requires.txt +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/pyproject.toml +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/setup.cfg +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_aws_dns_waiters.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_aws_vm_types.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_cloud_helpers.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_image_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_interface.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_multipart_driver.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_network_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_region_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_security_service.py +0 -0
- {cloudbridge-4.3.1 → cloudbridge-4.4.0}/tests/test_vm_types_service.py +0 -0
|
@@ -8,7 +8,6 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import queue
|
|
10
10
|
import re
|
|
11
|
-
import shutil
|
|
12
11
|
import threading
|
|
13
12
|
import time
|
|
14
13
|
import uuid
|
|
@@ -71,8 +70,6 @@ from cloudbridge.interfaces.resources import VolumeState
|
|
|
71
70
|
from . import helpers as cb_helpers
|
|
72
71
|
|
|
73
72
|
if TYPE_CHECKING:
|
|
74
|
-
from _typeshed import SupportsRead
|
|
75
|
-
|
|
76
73
|
from cloudbridge.base.provider import BaseCloudProvider
|
|
77
74
|
from cloudbridge.base.services import BaseStorageService
|
|
78
75
|
from cloudbridge.interfaces.services import BucketObjectService
|
|
@@ -840,6 +837,15 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
840
837
|
# Number of parts uploaded in parallel by the transparent multipart path.
|
|
841
838
|
CB_MULTIPART_MAX_CONCURRENCY = int(os.environ.get(
|
|
842
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
|
|
843
849
|
|
|
844
850
|
def __init__(self, provider: CloudProvider) -> None:
|
|
845
851
|
super(BaseBucketObject, self).__init__(provider)
|
|
@@ -878,12 +884,14 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
878
884
|
"in: http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMeta"
|
|
879
885
|
"data.html#object-key-guidelines" % name)
|
|
880
886
|
|
|
881
|
-
def save_content(self, target_stream: IO[bytes]
|
|
882
|
-
|
|
883
|
-
#
|
|
884
|
-
#
|
|
885
|
-
shutil.copyfileobj(
|
|
886
|
-
|
|
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)
|
|
887
895
|
|
|
888
896
|
def download_to_file(self, path: str,
|
|
889
897
|
config: TransferConfig | None = None) -> None:
|
|
@@ -1008,6 +1016,22 @@ class BaseBucketObject(BaseCloudResource, BucketObject):
|
|
|
1008
1016
|
return int(self._provider._get_config_value(
|
|
1009
1017
|
'multipart_max_concurrency', self.CB_MULTIPART_MAX_CONCURRENCY))
|
|
1010
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
|
+
|
|
1011
1035
|
@staticmethod
|
|
1012
1036
|
def _data_size(data: str | bytes | IO[bytes]) -> int | None:
|
|
1013
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
|
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -2132,14 +2132,31 @@ class GCPBucketObject(BaseBucketObject):
|
|
|
2132
2132
|
def last_modified(self) -> str:
|
|
2133
2133
|
return self._obj['updated']
|
|
2134
2134
|
|
|
2135
|
-
def iter_content(self) ->
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2135
|
+
def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
|
|
2136
|
+
"""
|
|
2137
|
+
Returns this object's content as an iterable of byte chunks.
|
|
2138
|
+
|
|
2139
|
+
google-api-python-client buffers whole responses, so there is no
|
|
2140
|
+
single-connection streaming read to be had here; content is fetched
|
|
2141
|
+
as successive ranged GETs of ``chunk_size`` bytes instead. That keeps
|
|
2142
|
+
memory flat at one chunk - this previously materialised the entire
|
|
2143
|
+
object in a ``BytesIO`` - at the cost of one request per chunk, so
|
|
2144
|
+
prefer a large ``chunk_size`` when streaming a large object, or
|
|
2145
|
+
:meth:`.download_to_file`, which fetches ranges in parallel.
|
|
2146
|
+
"""
|
|
2147
|
+
chunk_size = self._iter_chunk_size(chunk_size)
|
|
2148
|
+
bucket_objects = self._bucket_objects
|
|
2149
|
+
size = self.size
|
|
2150
|
+
|
|
2151
|
+
def range_iterator() -> Iterator[bytes]:
|
|
2152
|
+
offset = 0
|
|
2153
|
+
while offset < size:
|
|
2154
|
+
length = min(chunk_size, size - offset)
|
|
2155
|
+
yield bucket_objects.download_range(
|
|
2156
|
+
self.bucket, self.name, offset, length)
|
|
2157
|
+
offset += length
|
|
2158
|
+
|
|
2159
|
+
return range_iterator()
|
|
2143
2160
|
|
|
2144
2161
|
@property
|
|
2145
2162
|
def bucket(self) -> Bucket:
|
|
@@ -1389,10 +1389,14 @@ class OpenStackBucketObject(BaseBucketObject):
|
|
|
1389
1389
|
def last_modified(self) -> str:
|
|
1390
1390
|
return self._obj.get("last_modified")
|
|
1391
1391
|
|
|
1392
|
-
def iter_content(self) -> Iterable[bytes]:
|
|
1393
|
-
"""Returns this object's content as an iterable."""
|
|
1392
|
+
def iter_content(self, chunk_size: int | None = None) -> Iterable[bytes]:
|
|
1393
|
+
"""Returns this object's content as an iterable of byte chunks."""
|
|
1394
|
+
# resp_chunk_size makes swiftclient hand back a streaming _ObjectBody
|
|
1395
|
+
# (iterable, and file-like) instead of the whole object as bytes.
|
|
1396
|
+
chunk_size = self._iter_chunk_size(chunk_size)
|
|
1394
1397
|
_, content = cast("OpenStackCloudProvider", self._provider).swift \
|
|
1395
|
-
.get_object(self.cbcontainer.name, self.name,
|
|
1398
|
+
.get_object(self.cbcontainer.name, self.name,
|
|
1399
|
+
resp_chunk_size=chunk_size)
|
|
1396
1400
|
return content
|
|
1397
1401
|
|
|
1398
1402
|
@property
|
|
@@ -52,16 +52,19 @@ cloudbridge/providers/openstack/resources.py
|
|
|
52
52
|
cloudbridge/providers/openstack/services.py
|
|
53
53
|
cloudbridge/providers/openstack/subservices.py
|
|
54
54
|
tests/test_aws_dns_waiters.py
|
|
55
|
+
tests/test_aws_pagination.py
|
|
55
56
|
tests/test_aws_vm_types.py
|
|
56
57
|
tests/test_base_helpers.py
|
|
57
58
|
tests/test_block_store_service.py
|
|
58
59
|
tests/test_cloud_factory.py
|
|
59
60
|
tests/test_cloud_helpers.py
|
|
60
61
|
tests/test_compute_service.py
|
|
62
|
+
tests/test_default_network.py
|
|
61
63
|
tests/test_dns_service.py
|
|
62
64
|
tests/test_download_driver.py
|
|
63
65
|
tests/test_image_service.py
|
|
64
66
|
tests/test_interface.py
|
|
67
|
+
tests/test_iter_content.py
|
|
65
68
|
tests/test_middleware_system.py
|
|
66
69
|
tests/test_multipart_driver.py
|
|
67
70
|
tests/test_network_service.py
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for ``BotoEC2Service`` server-side pagination.
|
|
3
|
+
|
|
4
|
+
``_get_paginated_results`` hands boto3 a ``PaginationConfig`` built from two
|
|
5
|
+
separate numbers: ``MaxItems``, how many results the caller asked for, and
|
|
6
|
+
``PageSize``, how many the service returns per request. They used to be the
|
|
7
|
+
same value, which made a small result limit walk a large scan in tiny
|
|
8
|
+
increments - a filtered ``describe_images`` measured 977.6s at a page size of
|
|
9
|
+
5 against 10.0s at 1000, for the same single result.
|
|
10
|
+
|
|
11
|
+
Separating them moves where page boundaries fall, and the resume token
|
|
12
|
+
handed back to callers is defined in terms of those boundaries. These tests
|
|
13
|
+
pin the round trip: paging a collection with a small limit must still visit
|
|
14
|
+
every object exactly once and terminate, whatever the transport page size.
|
|
15
|
+
|
|
16
|
+
Exercised against moto so they run in CI without cloud credentials.
|
|
17
|
+
"""
|
|
18
|
+
import unittest
|
|
19
|
+
|
|
20
|
+
from moto import mock_aws
|
|
21
|
+
|
|
22
|
+
from cloudbridge.factory import CloudProviderFactory
|
|
23
|
+
from cloudbridge.factory import ProviderList
|
|
24
|
+
from cloudbridge.providers.aws.helpers import DEFAULT_PAGE_SIZE
|
|
25
|
+
|
|
26
|
+
# More objects than one page at the result limit below, so paging is forced.
|
|
27
|
+
OBJECT_COUNT = 7
|
|
28
|
+
RESULT_LIMIT = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AWSPaginationTestCase(unittest.TestCase):
|
|
32
|
+
|
|
33
|
+
def setUp(self):
|
|
34
|
+
self.mock = mock_aws()
|
|
35
|
+
self.mock.start()
|
|
36
|
+
self.provider = CloudProviderFactory().create_provider(
|
|
37
|
+
ProviderList.AWS,
|
|
38
|
+
{'aws_access_key': 'a', 'aws_secret_key': 'b',
|
|
39
|
+
'aws_region_name': 'us-east-1',
|
|
40
|
+
'default_result_limit': RESULT_LIMIT})
|
|
41
|
+
|
|
42
|
+
def tearDown(self):
|
|
43
|
+
self.mock.stop()
|
|
44
|
+
|
|
45
|
+
def _create_volumes(self):
|
|
46
|
+
created = []
|
|
47
|
+
for i in range(OBJECT_COUNT):
|
|
48
|
+
vol = self.provider.storage.volumes.create(
|
|
49
|
+
'cb-page-%d' % i, 1)
|
|
50
|
+
created.append(vol.id)
|
|
51
|
+
return created
|
|
52
|
+
|
|
53
|
+
def test_paging_visits_every_object_exactly_once(self):
|
|
54
|
+
expected = self._create_volumes()
|
|
55
|
+
|
|
56
|
+
seen = []
|
|
57
|
+
page = self.provider.storage.volumes.list()
|
|
58
|
+
seen.extend(o.id for o in page)
|
|
59
|
+
pages = 1
|
|
60
|
+
while page.is_truncated:
|
|
61
|
+
page = self.provider.storage.volumes.list(marker=page.marker)
|
|
62
|
+
seen.extend(o.id for o in page)
|
|
63
|
+
pages += 1
|
|
64
|
+
self.assertLess(pages, OBJECT_COUNT + 5,
|
|
65
|
+
"Paging failed to terminate; the resume token is "
|
|
66
|
+
"probably not advancing.")
|
|
67
|
+
|
|
68
|
+
self.assertEqual(sorted(seen), sorted(expected),
|
|
69
|
+
"Paging must visit every object exactly once.")
|
|
70
|
+
self.assertEqual(len(seen), len(set(seen)),
|
|
71
|
+
"Paging returned duplicates across pages.")
|
|
72
|
+
|
|
73
|
+
def test_a_page_holds_no_more_than_the_result_limit(self):
|
|
74
|
+
# The caller's limit bounds what comes back, independently of how
|
|
75
|
+
# much was fetched per request to produce it.
|
|
76
|
+
self._create_volumes()
|
|
77
|
+
page = self.provider.storage.volumes.list()
|
|
78
|
+
self.assertLessEqual(len(page), RESULT_LIMIT)
|
|
79
|
+
|
|
80
|
+
def _page_size(self, list_op):
|
|
81
|
+
# pylint:disable=protected-access
|
|
82
|
+
return self.provider.storage.volumes.svc._page_size(
|
|
83
|
+
self.provider.ec2_conn.meta.client, list_op)
|
|
84
|
+
|
|
85
|
+
def test_transport_page_size_is_independent_of_the_result_limit(self):
|
|
86
|
+
# The regression this guards: PageSize tracking the result limit is
|
|
87
|
+
# what made a sparse scan pathological.
|
|
88
|
+
page_size = self._page_size('describe_volumes')
|
|
89
|
+
self.assertEqual(page_size, DEFAULT_PAGE_SIZE)
|
|
90
|
+
self.assertNotEqual(page_size, RESULT_LIMIT)
|
|
91
|
+
|
|
92
|
+
def test_page_size_is_clamped_to_the_operations_ceiling(self):
|
|
93
|
+
# DescribeRouteTables allows 100 where most EC2 describes allow more,
|
|
94
|
+
# and exceeding a ceiling is a hard InvalidParameterValue.
|
|
95
|
+
self.assertEqual(self._page_size('describe_route_tables'), 100)
|
|
96
|
+
|
|
97
|
+
def test_page_size_survives_an_unknown_operation(self):
|
|
98
|
+
self.assertEqual(self._page_size('not_an_operation'),
|
|
99
|
+
DEFAULT_PAGE_SIZE)
|
|
100
|
+
|
|
101
|
+
def test_configured_page_size_is_used(self):
|
|
102
|
+
self.provider.config['aws_page_size'] = 250
|
|
103
|
+
self.assertEqual(self._page_size('describe_volumes'), 250)
|
|
104
|
+
|
|
105
|
+
def test_configured_page_size_is_clamped_to_the_ceiling(self):
|
|
106
|
+
# A configured value is no safer than the default: DescribeRouteTables
|
|
107
|
+
# would reject anything above 100 outright.
|
|
108
|
+
self.provider.config['aws_page_size'] = 900
|
|
109
|
+
self.assertEqual(self._page_size('describe_route_tables'), 100)
|
|
110
|
+
|
|
111
|
+
def test_configured_page_size_is_raised_to_the_floor(self):
|
|
112
|
+
# Several EC2 describes require at least 5, so a smaller configured
|
|
113
|
+
# value would be rejected rather than honoured.
|
|
114
|
+
self.provider.config['aws_page_size'] = 1
|
|
115
|
+
self.assertEqual(self._page_size('describe_vpcs'), 5)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
unittest.main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider-agnostic unit tests for ``BaseNetworkService.get_or_create_default``.
|
|
3
|
+
|
|
4
|
+
AWS and GCP override this method, and the mock provider used in CI is
|
|
5
|
+
AWS-backed, so the base implementation - the one Azure and OpenStack actually
|
|
6
|
+
inherit - is never executed by the networking service suite. It is exercised
|
|
7
|
+
here directly against in-memory fakes.
|
|
8
|
+
"""
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
from cloudbridge.base.resources import BaseNetwork
|
|
13
|
+
from cloudbridge.base.services import BaseNetworkService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _NetworkRecorder:
|
|
17
|
+
"""Stands in for provider.networking.networks."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, existing=None):
|
|
20
|
+
self.existing = existing or []
|
|
21
|
+
self.created = []
|
|
22
|
+
self.find_labels = []
|
|
23
|
+
|
|
24
|
+
def find(self, label=None, **kwargs):
|
|
25
|
+
self.find_labels.append(label)
|
|
26
|
+
return list(self.existing)
|
|
27
|
+
|
|
28
|
+
def create(self, label, cidr_block, **kwargs):
|
|
29
|
+
self.created.append((label, cidr_block))
|
|
30
|
+
return ("network", label, cidr_block)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _FakeProvider:
|
|
34
|
+
def __init__(self, networks):
|
|
35
|
+
self.middleware = mock.Mock()
|
|
36
|
+
self.networking = mock.Mock(networks=networks)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DefaultNetworkTestCase(unittest.TestCase):
|
|
40
|
+
|
|
41
|
+
def test_creates_default_network_with_the_default_cidr(self):
|
|
42
|
+
networks = _NetworkRecorder()
|
|
43
|
+
service = BaseNetworkService(_FakeProvider(networks))
|
|
44
|
+
|
|
45
|
+
# Patched away from the built-in so that a hardcoded 10.0.0.0/16 in
|
|
46
|
+
# the service cannot pass by coincidence.
|
|
47
|
+
with mock.patch.object(BaseNetwork, 'CB_DEFAULT_IPV4RANGE',
|
|
48
|
+
'192.168.0.0/16'):
|
|
49
|
+
service.get_or_create_default()
|
|
50
|
+
|
|
51
|
+
self.assertEqual(
|
|
52
|
+
networks.created,
|
|
53
|
+
[(BaseNetwork.CB_DEFAULT_NETWORK_LABEL, '192.168.0.0/16')],
|
|
54
|
+
"The default network must be created with the configured default "
|
|
55
|
+
"CIDR, not a hardcoded one.")
|
|
56
|
+
|
|
57
|
+
def test_returns_the_existing_default_network_without_creating(self):
|
|
58
|
+
networks = _NetworkRecorder(existing=["existing-net"])
|
|
59
|
+
service = BaseNetworkService(_FakeProvider(networks))
|
|
60
|
+
|
|
61
|
+
self.assertEqual(service.get_or_create_default(), "existing-net")
|
|
62
|
+
self.assertEqual(networks.created, [])
|
|
63
|
+
self.assertEqual(networks.find_labels,
|
|
64
|
+
[BaseNetwork.CB_DEFAULT_NETWORK_LABEL])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
unittest.main()
|
|
@@ -107,7 +107,7 @@ class _DriverObject(BaseBucketObject):
|
|
|
107
107
|
def bucket(self):
|
|
108
108
|
return "BUCKET"
|
|
109
109
|
|
|
110
|
-
def save_content(self, target_stream):
|
|
110
|
+
def save_content(self, target_stream, chunk_size=None):
|
|
111
111
|
self._provider._recorder.single_shot = True
|
|
112
112
|
target_stream.write(self._provider._recorder.content)
|
|
113
113
|
|