cloudbridge 4.4.0__tar.gz → 4.4.2__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.4.0 → cloudbridge-4.4.2}/PKG-INFO +2 -1
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/__init__.py +1 -1
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/provider.py +18 -8
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/helpers.py +102 -29
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/provider.py +2 -1
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/services.py +5 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/provider.py +23 -8
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge.egg-info/PKG-INFO +2 -1
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge.egg-info/SOURCES.txt +2 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge.egg-info/requires.txt +1 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/pyproject.toml +5 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_cloud_helpers.py +26 -0
- cloudbridge-4.4.2/tests/test_gcp_metadata_save.py +208 -0
- cloudbridge-4.4.2/tests/test_openstack_credentials.py +109 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/LICENSE +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/README.rst +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/helpers.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/middleware.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/services.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/base/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/factory.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/exceptions.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/provider.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/services.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/interfaces/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/helpers.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/provider.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/services.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/aws/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/azure_client.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/helpers.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/provider.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/services.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/azure/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/mock/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/mock/provider.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/__init__.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/helpers.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/resources.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/services.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/subservices.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge/py.typed +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge.egg-info/dependency_links.txt +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/cloudbridge.egg-info/top_level.txt +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/setup.cfg +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_aws_dns_waiters.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_aws_pagination.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_aws_vm_types.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_base_helpers.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_block_store_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_cloud_factory.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_compute_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_default_network.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_dns_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_download_driver.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_image_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_interface.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_iter_content.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_middleware_system.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_multipart_driver.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_network_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_object_life_cycle.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_object_store_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_region_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/tests/test_security_service.py +0 -0
- {cloudbridge-4.4.0 → cloudbridge-4.4.2}/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.4.
|
|
3
|
+
Version: 4.4.2
|
|
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
|
|
@@ -26,6 +26,7 @@ Description-Content-Type: text/x-rst
|
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: tenacity>=6.0
|
|
28
28
|
Requires-Dist: pyeventsystem<2
|
|
29
|
+
Requires-Dist: cryptography>=3.4
|
|
29
30
|
Provides-Extra: aws
|
|
30
31
|
Requires-Dist: boto3<2.0.0,>=1.9.86; extra == "aws"
|
|
31
32
|
Provides-Extra: azure
|
|
@@ -193,6 +193,13 @@ class BaseCloudProvider(CloudProvider):
|
|
|
193
193
|
"""
|
|
194
194
|
A convenience method to extract a configuration value.
|
|
195
195
|
|
|
196
|
+
The config dict is consulted first, then an attribute of the same
|
|
197
|
+
name on the config object, then the ``[<provider id>]`` section of
|
|
198
|
+
the cloudbridge ini file. A value is taken from the first source
|
|
199
|
+
that has it set; only ``None`` and the empty string count as unset,
|
|
200
|
+
so ``False`` and ``0`` are returned as configured rather than
|
|
201
|
+
replaced by the default.
|
|
202
|
+
|
|
196
203
|
:type key: str
|
|
197
204
|
:param key: a field to look for in the ``self.config`` field
|
|
198
205
|
|
|
@@ -204,12 +211,15 @@ class BaseCloudProvider(CloudProvider):
|
|
|
204
211
|
"""
|
|
205
212
|
log.debug("Getting config key %s, with supplied default value: %s",
|
|
206
213
|
key, default_value)
|
|
207
|
-
value =
|
|
208
|
-
if
|
|
209
|
-
value = self.config
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
elif (self._config_parser.has_option(self.PROVIDER_ID, key) and
|
|
213
|
-
self._config_parser.get(self.PROVIDER_ID, key)):
|
|
214
|
+
value = self.config.get(key) if isinstance(self.config, dict) else None
|
|
215
|
+
if not self._is_set(value):
|
|
216
|
+
value = getattr(self.config, key, None)
|
|
217
|
+
if (not self._is_set(value) and
|
|
218
|
+
self._config_parser.has_option(self.PROVIDER_ID, key)):
|
|
214
219
|
value = self._config_parser.get(self.PROVIDER_ID, key)
|
|
215
|
-
return value
|
|
220
|
+
return value if self._is_set(value) else default_value
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _is_set(value: Any) -> bool:
|
|
224
|
+
"""Whether a config value was supplied, as opposed to left blank."""
|
|
225
|
+
return value is not None and value != ''
|
|
@@ -13,12 +13,45 @@ from googleapiclient.errors import HttpError
|
|
|
13
13
|
|
|
14
14
|
import tenacity
|
|
15
15
|
|
|
16
|
+
from cloudbridge.interfaces.exceptions import DuplicateResourceException
|
|
16
17
|
from cloudbridge.interfaces.exceptions import ProviderInternalException
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from .provider import GCPCloudProvider
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
class GCPOperationError(ProviderInternalException):
|
|
24
|
+
"""A GCP operation completed with an error.
|
|
25
|
+
|
|
26
|
+
Raised by ``wait_for_operation`` with the operation's ``error`` payload,
|
|
27
|
+
so callers can act on the error codes rather than parse the message.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, error: Any) -> None:
|
|
31
|
+
super().__init__(error)
|
|
32
|
+
self.error = error
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def codes(self) -> list[str]:
|
|
36
|
+
return [e.get('code', '') for e in self.error.get('errors', [])]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MetadataWriteNotApplied(ProviderInternalException):
|
|
40
|
+
"""A common-metadata write completed as DONE but is absent when read back.
|
|
41
|
+
|
|
42
|
+
Observed under concurrent writers: GCP accepts several writes against
|
|
43
|
+
the same fingerprint while an earlier one is still pending, and a later
|
|
44
|
+
one can complete without its change surviving. The write is retried on
|
|
45
|
+
fresh metadata; this is raised only once the retries are exhausted.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, keys: list[str]) -> None:
|
|
49
|
+
super().__init__(
|
|
50
|
+
'Project metadata write reported done, but these keys do not '
|
|
51
|
+
'hold the written value: {}'.format(', '.join(keys)))
|
|
52
|
+
self.keys = keys
|
|
53
|
+
|
|
54
|
+
|
|
22
55
|
def gcp_projects(provider: "GCPCloudProvider") -> Any:
|
|
23
56
|
return provider.gcp_compute.projects()
|
|
24
57
|
|
|
@@ -43,8 +76,18 @@ def get_common_metadata(provider: "GCPCloudProvider") -> Any:
|
|
|
43
76
|
return metadata["commonInstanceMetadata"]
|
|
44
77
|
|
|
45
78
|
|
|
46
|
-
def
|
|
47
|
-
|
|
79
|
+
def __metadata_write_lost_to_a_concurrent_writer(e: BaseException) -> bool:
|
|
80
|
+
"""Whether ``e`` means a metadata write must be redone on fresh metadata.
|
|
81
|
+
|
|
82
|
+
A concurrent writer shows up in three shapes: an HTTP 412 on the request
|
|
83
|
+
itself, a submitted operation that completes with ``CONDITION_NOT_MET``,
|
|
84
|
+
or - when GCP accepted both writes against the same fingerprint - an
|
|
85
|
+
operation that completes as DONE without the change surviving.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(e, GCPOperationError):
|
|
88
|
+
return 'CONDITION_NOT_MET' in e.codes
|
|
89
|
+
if isinstance(e, MetadataWriteNotApplied):
|
|
90
|
+
return True
|
|
48
91
|
if isinstance(e, HttpError):
|
|
49
92
|
expected_message = 'Supplied fingerprint does not match current ' \
|
|
50
93
|
'metadata fingerprint.'
|
|
@@ -53,8 +96,14 @@ def __if_fingerprint_differs(e: BaseException) -> bool:
|
|
|
53
96
|
return False
|
|
54
97
|
|
|
55
98
|
|
|
99
|
+
def _metadata_values(metadata: Any) -> dict[str, Any]:
|
|
100
|
+
return {item['key']: item.get('value')
|
|
101
|
+
for item in metadata.get('items', [])}
|
|
102
|
+
|
|
103
|
+
|
|
56
104
|
@tenacity.retry(stop=tenacity.stop_after_attempt(10),
|
|
57
|
-
retry=tenacity.retry_if_exception(
|
|
105
|
+
retry=tenacity.retry_if_exception(
|
|
106
|
+
__metadata_write_lost_to_a_concurrent_writer),
|
|
58
107
|
wait=tenacity.wait_exponential(max=10),
|
|
59
108
|
reraise=True)
|
|
60
109
|
def gcp_metadata_save_op(provider: "GCPCloudProvider",
|
|
@@ -66,16 +115,36 @@ def gcp_metadata_save_op(provider: "GCPCloudProvider",
|
|
|
66
115
|
retrieves the metadata, invokes the provided callback with that
|
|
67
116
|
metadata, and saves the metadata using the original fingerprint
|
|
68
117
|
immediately afterwards, ensuring that update conflicts can be detected.
|
|
118
|
+
|
|
119
|
+
The fingerprint alone is not enough: a write that GCP reports as done
|
|
120
|
+
can still be missing from the document when another write was accepted
|
|
121
|
+
against the same fingerprint. So the write is only finished once the
|
|
122
|
+
callback's changes can be read back, and is redone on fresh metadata
|
|
123
|
+
otherwise. A callback that changes nothing sends no write at all - every
|
|
124
|
+
write re-uploads the whole document and moves the fingerprint, which
|
|
125
|
+
would only add contention for other writers.
|
|
69
126
|
"""
|
|
70
127
|
def _save_common_metadata(provider: "GCPCloudProvider") -> None:
|
|
71
128
|
# get the latest metadata (so we get the latest fingerprint)
|
|
72
129
|
metadata = get_common_metadata(provider)
|
|
130
|
+
before = _metadata_values(metadata)
|
|
73
131
|
# allow callback to do processing on it
|
|
74
132
|
callback(metadata)
|
|
133
|
+
wanted = _metadata_values(metadata)
|
|
134
|
+
changes = {key: wanted.get(key) for key in before.keys() | wanted.keys()
|
|
135
|
+
if before.get(key) != wanted.get(key)}
|
|
136
|
+
if not changes:
|
|
137
|
+
return
|
|
75
138
|
# save the metadata
|
|
76
139
|
operation = gcp_projects(provider).setCommonInstanceMetadata(
|
|
77
140
|
project=provider.project_name, body=metadata).execute()
|
|
78
141
|
provider.wait_for_operation(operation)
|
|
142
|
+
# ...and make sure it stuck
|
|
143
|
+
current = _metadata_values(get_common_metadata(provider))
|
|
144
|
+
lost = sorted(key for key, value in changes.items()
|
|
145
|
+
if current.get(key) != value)
|
|
146
|
+
if lost:
|
|
147
|
+
raise MetadataWriteNotApplied(lost)
|
|
79
148
|
|
|
80
149
|
# Retry a few times if the fingerprints conflict
|
|
81
150
|
_save_common_metadata(provider)
|
|
@@ -98,15 +167,24 @@ def modify_or_add_metadata_item(provider: "GCPCloudProvider", key: str,
|
|
|
98
167
|
gcp_metadata_save_op(provider, _update_metadata_key)
|
|
99
168
|
|
|
100
169
|
|
|
101
|
-
# This function will raise an HttpError with message containing
|
|
102
|
-
# "Metadata has duplicate key" if it's not unique, unlike the previous
|
|
103
|
-
# method which either adds or updates the value corresponding to that key
|
|
104
170
|
def add_metadata_item(provider: "GCPCloudProvider", key: str,
|
|
105
171
|
value: str) -> None:
|
|
172
|
+
"""Add ``key``, raising ``DuplicateResourceException`` if it is taken.
|
|
173
|
+
|
|
174
|
+
Unlike ``modify_or_add_metadata_item`` this never overwrites. The
|
|
175
|
+
check is done on the freshly fetched metadata so that a retry after a
|
|
176
|
+
lost write does not append a second copy of a key that did land: a key
|
|
177
|
+
already holding this very value is this write, and counts as done.
|
|
178
|
+
"""
|
|
106
179
|
def _add_metadata_key(metadata: Any) -> None:
|
|
107
|
-
entry = {'key': key, 'value': value}
|
|
108
180
|
entries = metadata.get('items', [])
|
|
109
|
-
entries
|
|
181
|
+
existing = [item for item in entries if item['key'] == key]
|
|
182
|
+
if existing:
|
|
183
|
+
if existing[-1].get('value') == value:
|
|
184
|
+
return
|
|
185
|
+
raise DuplicateResourceException(
|
|
186
|
+
'Metadata key {0} already exists'.format(key))
|
|
187
|
+
entries.append({'key': key, 'value': value})
|
|
110
188
|
# Reassign explicitly in case the original get returned [] although
|
|
111
189
|
# if not it will be already updated
|
|
112
190
|
metadata['items'] = entries
|
|
@@ -136,30 +214,25 @@ def get_metadata_item_value(provider: "GCPCloudProvider", key: str) -> Any:
|
|
|
136
214
|
|
|
137
215
|
|
|
138
216
|
def remove_metadata_item(provider: "GCPCloudProvider", key: str) -> bool:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# No metadata to delete
|
|
142
|
-
if not items:
|
|
143
|
-
return False
|
|
144
|
-
else:
|
|
145
|
-
entries = [item for item in metadata.get('items', [])
|
|
146
|
-
if item['key'] != key]
|
|
147
|
-
|
|
148
|
-
# Make sure only one entry is deleted
|
|
149
|
-
if len(entries) < len(items) - 1:
|
|
150
|
-
raise ProviderInternalException("Multiple metadata entries "
|
|
151
|
-
"found for the same key {}"
|
|
152
|
-
.format(key))
|
|
153
|
-
# If none is deleted indicate so by returning False
|
|
154
|
-
elif len(entries) == len(items):
|
|
155
|
-
return False
|
|
217
|
+
"""Remove ``key``; ``False`` if there was nothing to remove."""
|
|
218
|
+
removed = False
|
|
156
219
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
220
|
+
def _remove_metadata_by_key(metadata: Any) -> None:
|
|
221
|
+
nonlocal removed
|
|
222
|
+
items = metadata.get('items', [])
|
|
223
|
+
entries = [item for item in items if item['key'] != key]
|
|
224
|
+
# Make sure only one entry is deleted
|
|
225
|
+
if len(entries) < len(items) - 1:
|
|
226
|
+
raise ProviderInternalException("Multiple metadata entries "
|
|
227
|
+
"found for the same key {}"
|
|
228
|
+
.format(key))
|
|
229
|
+
# A retry after a lost write finds the key still present and
|
|
230
|
+
# removes it again; the first attempt decides whether it was there.
|
|
231
|
+
removed = removed or len(entries) < len(items)
|
|
232
|
+
metadata['items'] = entries
|
|
160
233
|
|
|
161
234
|
gcp_metadata_save_op(provider, _remove_metadata_by_key)
|
|
162
|
-
return
|
|
235
|
+
return removed
|
|
163
236
|
|
|
164
237
|
|
|
165
238
|
def __if_label_fingerprint_differs(e: BaseException) -> bool:
|
|
@@ -30,6 +30,7 @@ from cloudbridge.interfaces.services import NetworkingService
|
|
|
30
30
|
from cloudbridge.interfaces.services import SecurityService
|
|
31
31
|
from cloudbridge.interfaces.services import StorageService
|
|
32
32
|
|
|
33
|
+
from .helpers import GCPOperationError
|
|
33
34
|
from .services import GCPComputeService
|
|
34
35
|
from .services import GCPDnsService
|
|
35
36
|
from .services import GCPNetworkingService
|
|
@@ -401,7 +402,7 @@ class GCPCloudProvider(BaseCloudProvider):
|
|
|
401
402
|
result = operations.get(**args).execute()
|
|
402
403
|
if result['status'] == 'DONE':
|
|
403
404
|
if 'error' in result:
|
|
404
|
-
raise
|
|
405
|
+
raise GCPOperationError(result['error'])
|
|
405
406
|
return result
|
|
406
407
|
|
|
407
408
|
time.sleep(0.5)
|
|
@@ -188,7 +188,12 @@ class GCPKeyPairService(BaseKeyPairService):
|
|
|
188
188
|
GCPKeyPair.KP_TAG_PREFIX + name,
|
|
189
189
|
metadata_value)
|
|
190
190
|
return GCPKeyPair(provider, kp_info, private_key)
|
|
191
|
+
except DuplicateResourceException:
|
|
192
|
+
raise DuplicateResourceException(
|
|
193
|
+
'A KeyPair with name {0} already exists'.format(name))
|
|
191
194
|
except googleapiclient.errors.HttpError as err:
|
|
195
|
+
# GCP's own duplicate-key rejection, should a concurrent create
|
|
196
|
+
# slip past the check on the fetched metadata.
|
|
192
197
|
if err.resp.get('content-type', '').startswith('application/json'):
|
|
193
198
|
message = (json.loads(err.content).get('error', {})
|
|
194
199
|
.get('errors', [{}])[0].get('message'))
|
|
@@ -44,14 +44,29 @@ class OpenStackCloudProvider(BaseCloudProvider):
|
|
|
44
44
|
super(OpenStackCloudProvider, self).__init__(config)
|
|
45
45
|
|
|
46
46
|
# Initialize cloud connection fields
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
# Credentials come from the config first and the OS_* environment
|
|
48
|
+
# second. The two credential sets are resolved separately: the
|
|
49
|
+
# environment only completes the set the config names, so a provider
|
|
50
|
+
# configured with an application credential never picks up the
|
|
51
|
+
# process's own OS_USERNAME/OS_PASSWORD and signs in as that identity
|
|
52
|
+
# instead (and vice versa). With neither set configured, both are read
|
|
53
|
+
# from the environment as before.
|
|
54
|
+
app_cred_id = self._get_config_value('os_application_credential_id')
|
|
55
|
+
app_cred_secret = self._get_config_value(
|
|
56
|
+
'os_application_credential_secret')
|
|
57
|
+
username = self._get_config_value('os_username')
|
|
58
|
+
password = self._get_config_value('os_password')
|
|
59
|
+
env_app_cred = not (username or password)
|
|
60
|
+
env_password = not (app_cred_id or app_cred_secret)
|
|
61
|
+
self.app_cred_id = app_cred_id or (
|
|
62
|
+
get_env('OS_APPLICATION_CREDENTIAL_ID') if env_app_cred else None)
|
|
63
|
+
self.app_cred_secret = app_cred_secret or (
|
|
64
|
+
get_env('OS_APPLICATION_CREDENTIAL_SECRET') if env_app_cred
|
|
65
|
+
else None)
|
|
66
|
+
self.username = username or (
|
|
67
|
+
get_env('OS_USERNAME') if env_password else None)
|
|
68
|
+
self.password = password or (
|
|
69
|
+
get_env('OS_PASSWORD') if env_password else None)
|
|
55
70
|
self.project_name = self._get_config_value(
|
|
56
71
|
'os_project_name', get_env('OS_PROJECT_NAME')
|
|
57
72
|
or get_env('OS_TENANT_NAME'))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudbridge
|
|
3
|
-
Version: 4.4.
|
|
3
|
+
Version: 4.4.2
|
|
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
|
|
@@ -26,6 +26,7 @@ Description-Content-Type: text/x-rst
|
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: tenacity>=6.0
|
|
28
28
|
Requires-Dist: pyeventsystem<2
|
|
29
|
+
Requires-Dist: cryptography>=3.4
|
|
29
30
|
Provides-Extra: aws
|
|
30
31
|
Requires-Dist: boto3<2.0.0,>=1.9.86; extra == "aws"
|
|
31
32
|
Provides-Extra: azure
|
|
@@ -62,6 +62,7 @@ tests/test_compute_service.py
|
|
|
62
62
|
tests/test_default_network.py
|
|
63
63
|
tests/test_dns_service.py
|
|
64
64
|
tests/test_download_driver.py
|
|
65
|
+
tests/test_gcp_metadata_save.py
|
|
65
66
|
tests/test_image_service.py
|
|
66
67
|
tests/test_interface.py
|
|
67
68
|
tests/test_iter_content.py
|
|
@@ -70,6 +71,7 @@ tests/test_multipart_driver.py
|
|
|
70
71
|
tests/test_network_service.py
|
|
71
72
|
tests/test_object_life_cycle.py
|
|
72
73
|
tests/test_object_store_service.py
|
|
74
|
+
tests/test_openstack_credentials.py
|
|
73
75
|
tests/test_region_service.py
|
|
74
76
|
tests/test_security_service.py
|
|
75
77
|
tests/test_vm_types_service.py
|
|
@@ -31,6 +31,11 @@ classifiers = [
|
|
|
31
31
|
dependencies = [
|
|
32
32
|
"tenacity>=6.0",
|
|
33
33
|
"pyeventsystem<2",
|
|
34
|
+
# Imported at module scope by cloudbridge.base.helpers, which almost
|
|
35
|
+
# everything else imports, so it is required for the library to import at
|
|
36
|
+
# all - not just for generate_key_pair, which is what actually uses it.
|
|
37
|
+
# Kept unpinned above the floor, per the general-purpose library policy.
|
|
38
|
+
"cryptography>=3.4",
|
|
34
39
|
]
|
|
35
40
|
dynamic = ["version"]
|
|
36
41
|
|
|
@@ -98,3 +98,29 @@ class CloudHelpersTestCase(ProviderTestBase):
|
|
|
98
98
|
int_value = self.provider._get_config_value(
|
|
99
99
|
'default_result_limit', None)
|
|
100
100
|
self.assertIsInstance(int_value, int)
|
|
101
|
+
|
|
102
|
+
def test_config_value_set_to_false_is_honored(self):
|
|
103
|
+
# A boolean option turned off must not be mistaken for an unset one:
|
|
104
|
+
# `s3_validate_certs: False` has to reach the SDK as False, not as
|
|
105
|
+
# the default of True.
|
|
106
|
+
self.provider.config['falsy_bool_check'] = False
|
|
107
|
+
# pylint:disable=protected-access
|
|
108
|
+
self.assertIs(
|
|
109
|
+
self.provider._get_config_value('falsy_bool_check', True), False)
|
|
110
|
+
|
|
111
|
+
def test_config_value_set_to_zero_is_honored(self):
|
|
112
|
+
self.provider.config['falsy_int_check'] = 0
|
|
113
|
+
# pylint:disable=protected-access
|
|
114
|
+
self.assertEqual(
|
|
115
|
+
self.provider._get_config_value('falsy_int_check', 4), 0)
|
|
116
|
+
|
|
117
|
+
def test_config_value_none_or_blank_falls_back_to_default(self):
|
|
118
|
+
# None and the empty string are what an absent value looks like
|
|
119
|
+
# coming from YAML, a blank environment variable or a blank ini
|
|
120
|
+
# option, so those alone mean "not configured".
|
|
121
|
+
for unset in (None, ''):
|
|
122
|
+
self.provider.config['unset_check'] = unset
|
|
123
|
+
# pylint:disable=protected-access
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
self.provider._get_config_value('unset_check', 'default'),
|
|
126
|
+
'default', repr(unset))
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Making GCP common-metadata writes stick.
|
|
2
|
+
|
|
3
|
+
GCP keeps labels and key pairs in the project-wide common instance metadata,
|
|
4
|
+
which every write re-uploads under an optimistic fingerprint. Under
|
|
5
|
+
concurrent writers two things happen that a write has to survive: the
|
|
6
|
+
operation fails with ``CONDITION_NOT_MET`` (a different path from the
|
|
7
|
+
HTTP-level 412), and - observed in the live suite - the operation reports
|
|
8
|
+
``DONE`` yet the change is absent from the document afterwards. Both must
|
|
9
|
+
lead to the write being re-applied on freshly fetched metadata. No SDK is
|
|
10
|
+
involved: the compute client is a fake with a server-side document, and the
|
|
11
|
+
provider's real ``wait_for_operation`` polls it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import unittest
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
import tenacity
|
|
18
|
+
|
|
19
|
+
from cloudbridge.interfaces.exceptions import DuplicateResourceException
|
|
20
|
+
from cloudbridge.providers.gcp.helpers import GCPOperationError
|
|
21
|
+
from cloudbridge.providers.gcp.helpers import MetadataWriteNotApplied
|
|
22
|
+
from cloudbridge.providers.gcp.helpers import add_metadata_item
|
|
23
|
+
from cloudbridge.providers.gcp.helpers import gcp_metadata_save_op
|
|
24
|
+
from cloudbridge.providers.gcp.helpers import modify_or_add_metadata_item
|
|
25
|
+
from cloudbridge.providers.gcp.helpers import remove_metadata_item
|
|
26
|
+
from cloudbridge.providers.gcp.provider import GCPCloudProvider
|
|
27
|
+
|
|
28
|
+
FINGERPRINT_CONFLICT = {
|
|
29
|
+
'errors': [{'code': 'CONDITION_NOT_MET',
|
|
30
|
+
'message': 'Supplied fingerprint does not match current '
|
|
31
|
+
'metadata fingerprint.'}]}
|
|
32
|
+
OTHER_FAILURE = {
|
|
33
|
+
'errors': [{'code': 'RESOURCE_NOT_FOUND',
|
|
34
|
+
'message': "The resource 'projects/p' was not found"}]}
|
|
35
|
+
|
|
36
|
+
# Scripted outcomes for successive set operations.
|
|
37
|
+
APPLY = 'apply' # the write lands and the fingerprint moves on
|
|
38
|
+
LOST = 'lost' # reported DONE, but nothing changed
|
|
39
|
+
CONFLICT = 'conflict' # CONDITION_NOT_MET, nothing changed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Call:
|
|
43
|
+
def __init__(self, result):
|
|
44
|
+
self._result = result
|
|
45
|
+
|
|
46
|
+
def execute(self):
|
|
47
|
+
return self._result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _FakeCompute:
|
|
51
|
+
"""A compute client holding one project metadata document.
|
|
52
|
+
|
|
53
|
+
``projects().get()`` returns a copy of the document; each
|
|
54
|
+
``setCommonInstanceMetadata()`` yields an operation whose fate is the
|
|
55
|
+
next entry in ``outcomes``, resolved when ``wait_for_operation`` polls
|
|
56
|
+
``globalOperations().get()``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, outcomes, items=None):
|
|
60
|
+
self.outcomes = list(outcomes)
|
|
61
|
+
self.document = {'fingerprint': 'fp-0',
|
|
62
|
+
'items': list(items or [])}
|
|
63
|
+
self.version = 0
|
|
64
|
+
self.fetches = 0
|
|
65
|
+
self.saved_bodies = []
|
|
66
|
+
self._pending = {}
|
|
67
|
+
|
|
68
|
+
def projects(self):
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def globalOperations(self):
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def get(self, project, operation=None):
|
|
75
|
+
if operation is not None:
|
|
76
|
+
return self._resolve(operation)
|
|
77
|
+
self.fetches += 1
|
|
78
|
+
return _Call({'commonInstanceMetadata': {
|
|
79
|
+
'fingerprint': self.document['fingerprint'],
|
|
80
|
+
'items': [dict(i) for i in self.document['items']]}})
|
|
81
|
+
|
|
82
|
+
def setCommonInstanceMetadata(self, project, body):
|
|
83
|
+
self.saved_bodies.append(body)
|
|
84
|
+
name = 'op-%d' % len(self.saved_bodies)
|
|
85
|
+
self._pending[name] = body
|
|
86
|
+
return _Call({'name': name})
|
|
87
|
+
|
|
88
|
+
def _resolve(self, name):
|
|
89
|
+
body = self._pending.pop(name)
|
|
90
|
+
outcome = self.outcomes.pop(0)
|
|
91
|
+
result = {'status': 'DONE'}
|
|
92
|
+
if outcome == APPLY:
|
|
93
|
+
self.version += 1
|
|
94
|
+
self.document = {'fingerprint': 'fp-%d' % self.version,
|
|
95
|
+
'items': [dict(i) for i in body['items']]}
|
|
96
|
+
elif outcome == CONFLICT:
|
|
97
|
+
result['error'] = FINGERPRINT_CONFLICT
|
|
98
|
+
elif outcome == LOST:
|
|
99
|
+
pass
|
|
100
|
+
else:
|
|
101
|
+
result['error'] = outcome
|
|
102
|
+
return _Call(result)
|
|
103
|
+
|
|
104
|
+
def keys(self):
|
|
105
|
+
return {i['key']: i['value'] for i in self.document['items']}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _FakeProvider:
|
|
109
|
+
project_name = 'p'
|
|
110
|
+
wait_for_operation = GCPCloudProvider.wait_for_operation
|
|
111
|
+
|
|
112
|
+
def __init__(self, outcomes, items=None):
|
|
113
|
+
self.gcp_compute = _FakeCompute(outcomes, items)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class GCPMetadataSaveTestCase(unittest.TestCase):
|
|
117
|
+
|
|
118
|
+
def setUp(self):
|
|
119
|
+
# The production wait between attempts is exponential backoff; these
|
|
120
|
+
# tests are about whether a retry happens, not how long it waits.
|
|
121
|
+
patcher = mock.patch.object(gcp_metadata_save_op.retry, 'wait',
|
|
122
|
+
tenacity.wait_none())
|
|
123
|
+
patcher.start()
|
|
124
|
+
self.addCleanup(patcher.stop)
|
|
125
|
+
|
|
126
|
+
def test_fingerprint_conflict_is_retried_with_fresh_metadata(self):
|
|
127
|
+
provider = _FakeProvider([CONFLICT, APPLY])
|
|
128
|
+
callback = mock.Mock(side_effect=lambda md: md['items'].append(
|
|
129
|
+
{'key': 'k', 'value': 'v'}))
|
|
130
|
+
|
|
131
|
+
gcp_metadata_save_op(provider, callback)
|
|
132
|
+
|
|
133
|
+
# Two attempts, each on metadata fetched anew so the retry carries
|
|
134
|
+
# the fingerprint the conflict invalidated.
|
|
135
|
+
self.assertEqual(callback.call_count, 2)
|
|
136
|
+
self.assertEqual(
|
|
137
|
+
[b['fingerprint'] for b in provider.gcp_compute.saved_bodies],
|
|
138
|
+
['fp-0', 'fp-0'])
|
|
139
|
+
self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
|
|
140
|
+
|
|
141
|
+
def test_write_reported_done_but_absent_is_reapplied(self):
|
|
142
|
+
# The live suite showed operations completing as DONE with the
|
|
143
|
+
# change missing from the document. A write is only finished once
|
|
144
|
+
# it can be read back.
|
|
145
|
+
provider = _FakeProvider([LOST, APPLY])
|
|
146
|
+
|
|
147
|
+
modify_or_add_metadata_item(provider, 'k', 'v')
|
|
148
|
+
|
|
149
|
+
self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
|
|
150
|
+
self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
|
|
151
|
+
|
|
152
|
+
def test_write_that_never_lands_is_reported_after_retries(self):
|
|
153
|
+
provider = _FakeProvider([LOST] * 10)
|
|
154
|
+
|
|
155
|
+
with self.assertRaises(MetadataWriteNotApplied) as raised:
|
|
156
|
+
modify_or_add_metadata_item(provider, 'k', 'v')
|
|
157
|
+
|
|
158
|
+
self.assertIn('k', str(raised.exception))
|
|
159
|
+
self.assertEqual(len(provider.gcp_compute.saved_bodies), 10)
|
|
160
|
+
|
|
161
|
+
def test_other_operation_failures_are_raised_as_typed_errors(self):
|
|
162
|
+
provider = _FakeProvider([OTHER_FAILURE])
|
|
163
|
+
callback = mock.Mock(side_effect=lambda md: md['items'].append(
|
|
164
|
+
{'key': 'k', 'value': 'v'}))
|
|
165
|
+
|
|
166
|
+
with self.assertRaises(GCPOperationError) as raised:
|
|
167
|
+
gcp_metadata_save_op(provider, callback)
|
|
168
|
+
|
|
169
|
+
self.assertEqual(callback.call_count, 1)
|
|
170
|
+
self.assertEqual(raised.exception.codes, ['RESOURCE_NOT_FOUND'])
|
|
171
|
+
self.assertIn("was not found", str(raised.exception))
|
|
172
|
+
|
|
173
|
+
def test_a_write_that_changes_nothing_is_not_sent(self):
|
|
174
|
+
# Every set re-uploads the whole document and moves the fingerprint,
|
|
175
|
+
# so a no-op (removing an absent key, re-setting the current value)
|
|
176
|
+
# would only add contention for other writers.
|
|
177
|
+
provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
|
|
178
|
+
|
|
179
|
+
self.assertFalse(remove_metadata_item(provider, 'absent'))
|
|
180
|
+
modify_or_add_metadata_item(provider, 'k', 'v')
|
|
181
|
+
|
|
182
|
+
self.assertEqual(provider.gcp_compute.saved_bodies, [])
|
|
183
|
+
|
|
184
|
+
def test_remove_is_reapplied_until_the_key_is_gone(self):
|
|
185
|
+
provider = _FakeProvider([LOST, APPLY],
|
|
186
|
+
items=[{'key': 'k', 'value': 'v'}])
|
|
187
|
+
|
|
188
|
+
self.assertTrue(remove_metadata_item(provider, 'k'))
|
|
189
|
+
|
|
190
|
+
self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
|
|
191
|
+
self.assertEqual(provider.gcp_compute.keys(), {})
|
|
192
|
+
|
|
193
|
+
def test_add_is_satisfied_by_its_own_value_already_present(self):
|
|
194
|
+
# A retry must not append a second copy if the earlier attempt did
|
|
195
|
+
# land after all.
|
|
196
|
+
provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
|
|
197
|
+
|
|
198
|
+
add_metadata_item(provider, 'k', 'v')
|
|
199
|
+
|
|
200
|
+
self.assertEqual(provider.gcp_compute.saved_bodies, [])
|
|
201
|
+
|
|
202
|
+
def test_add_rejects_a_key_someone_else_holds(self):
|
|
203
|
+
provider = _FakeProvider([], items=[{'key': 'k', 'value': 'theirs'}])
|
|
204
|
+
|
|
205
|
+
with self.assertRaises(DuplicateResourceException):
|
|
206
|
+
add_metadata_item(provider, 'k', 'mine')
|
|
207
|
+
|
|
208
|
+
self.assertEqual(provider.gcp_compute.saved_bodies, [])
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Which OpenStack credentials a provider authenticates with.
|
|
2
|
+
|
|
3
|
+
The provider reads credentials from its config dict and, failing that, from
|
|
4
|
+
the ``OS_*`` environment. These tests pin the precedence between the two:
|
|
5
|
+
credentials configured explicitly win over whatever happens to be in the
|
|
6
|
+
process environment, so a provider built for one identity never signs in as
|
|
7
|
+
another. Nothing here touches a network: the Keystone version probe is
|
|
8
|
+
patched and keystoneauth plugins are plain objects until used.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import unittest
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
from keystoneauth1.identity import v3
|
|
16
|
+
|
|
17
|
+
from cloudbridge.providers.openstack.provider import OpenStackCloudProvider
|
|
18
|
+
|
|
19
|
+
AUTH_URL = 'https://keystone.example.org:5000/v3'
|
|
20
|
+
|
|
21
|
+
PASSWORD_ENV = {
|
|
22
|
+
'OS_USERNAME': 'ambient-user',
|
|
23
|
+
'OS_PASSWORD': 'ambient-password',
|
|
24
|
+
'OS_PROJECT_NAME': 'ambient-project',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
APP_CRED_ENV = {
|
|
28
|
+
'OS_APPLICATION_CREDENTIAL_ID': 'ambient-app-cred-id',
|
|
29
|
+
'OS_APPLICATION_CREDENTIAL_SECRET': 'ambient-app-cred-secret',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ALL_CREDENTIAL_VARS = tuple(PASSWORD_ENV) + tuple(APP_CRED_ENV)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _environment(**values):
|
|
36
|
+
"""The process environment with only the given OS_* credentials set."""
|
|
37
|
+
env = {k: v for k, v in os.environ.items() if k not in ALL_CREDENTIAL_VARS}
|
|
38
|
+
env.update(values)
|
|
39
|
+
return mock.patch.dict(os.environ, env, clear=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _provider(**config):
|
|
43
|
+
# A configured zone keeps the compute service from asking Nova for one
|
|
44
|
+
# while the provider is being built.
|
|
45
|
+
return OpenStackCloudProvider(
|
|
46
|
+
dict(config, os_auth_url=AUTH_URL, os_zone_name='nova'))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _keystone_auth(provider):
|
|
50
|
+
with mock.patch.object(OpenStackCloudProvider, '_keystone_version',
|
|
51
|
+
new_callable=mock.PropertyMock, return_value=3):
|
|
52
|
+
# pylint:disable=protected-access
|
|
53
|
+
return provider._keystone_session.auth
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OpenStackCredentialPrecedenceTestCase(unittest.TestCase):
|
|
57
|
+
|
|
58
|
+
def test_configured_application_credential_ignores_ambient_password(self):
|
|
59
|
+
# The process may carry the server's own OS_USERNAME/OS_PASSWORD; a
|
|
60
|
+
# provider configured with an application credential must use that
|
|
61
|
+
# credential, not the ambient identity.
|
|
62
|
+
with _environment(**PASSWORD_ENV):
|
|
63
|
+
provider = _provider(
|
|
64
|
+
os_application_credential_id='configured-id',
|
|
65
|
+
os_application_credential_secret='configured-secret')
|
|
66
|
+
self.assertIsNone(provider.username)
|
|
67
|
+
self.assertIsNone(provider.password)
|
|
68
|
+
auth = _keystone_auth(provider)
|
|
69
|
+
self.assertIsInstance(auth, v3.ApplicationCredential)
|
|
70
|
+
self.assertEqual(auth.auth_methods[0].application_credential_id,
|
|
71
|
+
'configured-id')
|
|
72
|
+
|
|
73
|
+
def test_configured_password_ignores_ambient_application_credential(self):
|
|
74
|
+
with _environment(**APP_CRED_ENV):
|
|
75
|
+
provider = _provider(os_username='configured-user',
|
|
76
|
+
os_password='configured-password',
|
|
77
|
+
os_project_name='configured-project')
|
|
78
|
+
self.assertIsNone(provider.app_cred_id)
|
|
79
|
+
self.assertIsNone(provider.app_cred_secret)
|
|
80
|
+
auth = _keystone_auth(provider)
|
|
81
|
+
self.assertIsInstance(auth, v3.Password)
|
|
82
|
+
self.assertEqual(auth.auth_methods[0].username, 'configured-user')
|
|
83
|
+
|
|
84
|
+
def test_environment_is_used_when_nothing_is_configured(self):
|
|
85
|
+
with _environment(**PASSWORD_ENV):
|
|
86
|
+
provider = _provider()
|
|
87
|
+
self.assertEqual(provider.username, 'ambient-user')
|
|
88
|
+
self.assertEqual(provider.password, 'ambient-password')
|
|
89
|
+
auth = _keystone_auth(provider)
|
|
90
|
+
self.assertIsInstance(auth, v3.Password)
|
|
91
|
+
|
|
92
|
+
def test_environment_completes_a_partially_configured_credential(self):
|
|
93
|
+
# Keeping the secret out of the config file and in the environment is
|
|
94
|
+
# legitimate: the environment fills in the missing half of the set
|
|
95
|
+
# that is configured, and only that set.
|
|
96
|
+
with _environment(**PASSWORD_ENV, **APP_CRED_ENV):
|
|
97
|
+
provider = _provider(os_username='configured-user')
|
|
98
|
+
self.assertEqual(provider.username, 'configured-user')
|
|
99
|
+
self.assertEqual(provider.password, 'ambient-password')
|
|
100
|
+
self.assertIsNone(provider.app_cred_id)
|
|
101
|
+
self.assertIsNone(provider.app_cred_secret)
|
|
102
|
+
|
|
103
|
+
with _environment(**PASSWORD_ENV, **APP_CRED_ENV):
|
|
104
|
+
provider = _provider(os_application_credential_id='configured-id')
|
|
105
|
+
self.assertEqual(provider.app_cred_id, 'configured-id')
|
|
106
|
+
self.assertEqual(provider.app_cred_secret,
|
|
107
|
+
'ambient-app-cred-secret')
|
|
108
|
+
self.assertIsNone(provider.username)
|
|
109
|
+
self.assertIsNone(provider.password)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|