cloudbridge 4.4.1__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.
Files changed (78) hide show
  1. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/PKG-INFO +1 -1
  2. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/__init__.py +1 -1
  3. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/provider.py +18 -8
  4. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/helpers.py +102 -29
  5. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/provider.py +2 -1
  6. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/services.py +5 -0
  7. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/provider.py +23 -8
  8. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge.egg-info/PKG-INFO +1 -1
  9. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge.egg-info/SOURCES.txt +2 -0
  10. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_cloud_helpers.py +26 -0
  11. cloudbridge-4.4.2/tests/test_gcp_metadata_save.py +208 -0
  12. cloudbridge-4.4.2/tests/test_openstack_credentials.py +109 -0
  13. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/LICENSE +0 -0
  14. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/README.rst +0 -0
  15. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/__init__.py +0 -0
  16. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/helpers.py +0 -0
  17. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/middleware.py +0 -0
  18. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/resources.py +0 -0
  19. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/services.py +0 -0
  20. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/base/subservices.py +0 -0
  21. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/factory.py +0 -0
  22. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/__init__.py +0 -0
  23. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/exceptions.py +0 -0
  24. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/provider.py +0 -0
  25. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/resources.py +0 -0
  26. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/services.py +0 -0
  27. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/interfaces/subservices.py +0 -0
  28. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/__init__.py +0 -0
  29. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/__init__.py +0 -0
  30. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/helpers.py +0 -0
  31. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/provider.py +0 -0
  32. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/resources.py +0 -0
  33. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/services.py +0 -0
  34. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/aws/subservices.py +0 -0
  35. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/__init__.py +0 -0
  36. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/azure_client.py +0 -0
  37. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/helpers.py +0 -0
  38. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/provider.py +0 -0
  39. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/resources.py +0 -0
  40. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/services.py +0 -0
  41. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/azure/subservices.py +0 -0
  42. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/__init__.py +0 -0
  43. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/resources.py +0 -0
  44. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/gcp/subservices.py +0 -0
  45. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/mock/__init__.py +0 -0
  46. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/mock/provider.py +0 -0
  47. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/__init__.py +0 -0
  48. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/helpers.py +0 -0
  49. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/resources.py +0 -0
  50. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/services.py +0 -0
  51. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/providers/openstack/subservices.py +0 -0
  52. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge/py.typed +0 -0
  53. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge.egg-info/dependency_links.txt +0 -0
  54. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge.egg-info/requires.txt +0 -0
  55. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/cloudbridge.egg-info/top_level.txt +0 -0
  56. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/pyproject.toml +0 -0
  57. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/setup.cfg +0 -0
  58. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_aws_dns_waiters.py +0 -0
  59. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_aws_pagination.py +0 -0
  60. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_aws_vm_types.py +0 -0
  61. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_base_helpers.py +0 -0
  62. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_block_store_service.py +0 -0
  63. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_cloud_factory.py +0 -0
  64. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_compute_service.py +0 -0
  65. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_default_network.py +0 -0
  66. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_dns_service.py +0 -0
  67. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_download_driver.py +0 -0
  68. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_image_service.py +0 -0
  69. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_interface.py +0 -0
  70. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_iter_content.py +0 -0
  71. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_middleware_system.py +0 -0
  72. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_multipart_driver.py +0 -0
  73. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_network_service.py +0 -0
  74. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_object_life_cycle.py +0 -0
  75. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_object_store_service.py +0 -0
  76. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_region_service.py +0 -0
  77. {cloudbridge-4.4.1 → cloudbridge-4.4.2}/tests/test_security_service.py +0 -0
  78. {cloudbridge-4.4.1 → 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.1
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
@@ -3,7 +3,7 @@ import logging
3
3
  from typing import Any
4
4
 
5
5
  # Current version of the library
6
- __version__ = '4.4.1'
6
+ __version__ = '4.4.2'
7
7
 
8
8
 
9
9
  def get_version() -> str:
@@ -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 = default_value
208
- if isinstance(self.config, dict) and self.config.get(key):
209
- value = self.config.get(key, default_value)
210
- elif hasattr(self.config, key) and getattr(self.config, key):
211
- value = getattr(self.config, key)
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 __if_fingerprint_differs(e: BaseException) -> bool:
47
- # return True if the CloudError exception is due to subnet being in use
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(__if_fingerprint_differs),
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.append(entry)
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
- def _remove_metadata_by_key(metadata: Any) -> bool | None:
140
- items = metadata.get('items', [])
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
- else:
158
- metadata['items'] = entries
159
- return None
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 True
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 Exception(result['error'])
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
- self.app_cred_id = self._get_config_value(
48
- 'os_application_credential_id', get_env('OS_APPLICATION_CREDENTIAL_ID'))
49
- self.app_cred_secret = self._get_config_value(
50
- 'os_application_credential_secret', get_env('OS_APPLICATION_CREDENTIAL_SECRET'))
51
- self.username = self._get_config_value(
52
- 'os_username', get_env('OS_USERNAME'))
53
- self.password = self._get_config_value(
54
- 'os_password', get_env('OS_PASSWORD'))
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.1
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
@@ -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
@@ -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