test_gcp_metadata_save.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Making GCP common-metadata writes stick.
  2. GCP keeps labels and key pairs in the project-wide common instance metadata,
  3. which every write re-uploads under an optimistic fingerprint. Under
  4. concurrent writers two things happen that a write has to survive: the
  5. operation fails with ``CONDITION_NOT_MET`` (a different path from the
  6. HTTP-level 412), and - observed in the live suite - the operation reports
  7. ``DONE`` yet the change is absent from the document afterwards. Both must
  8. lead to the write being re-applied on freshly fetched metadata. No SDK is
  9. involved: the compute client is a fake with a server-side document, and the
  10. provider's real ``wait_for_operation`` polls it.
  11. """
  12. import unittest
  13. from unittest import mock
  14. import tenacity
  15. from cloudbridge.interfaces.exceptions import DuplicateResourceException
  16. from cloudbridge.providers.gcp.helpers import GCPOperationError
  17. from cloudbridge.providers.gcp.helpers import MetadataWriteNotApplied
  18. from cloudbridge.providers.gcp.helpers import add_metadata_item
  19. from cloudbridge.providers.gcp.helpers import gcp_metadata_save_op
  20. from cloudbridge.providers.gcp.helpers import modify_or_add_metadata_item
  21. from cloudbridge.providers.gcp.helpers import remove_metadata_item
  22. from cloudbridge.providers.gcp.provider import GCPCloudProvider
  23. FINGERPRINT_CONFLICT = {
  24. 'errors': [{'code': 'CONDITION_NOT_MET',
  25. 'message': 'Supplied fingerprint does not match current '
  26. 'metadata fingerprint.'}]}
  27. OTHER_FAILURE = {
  28. 'errors': [{'code': 'RESOURCE_NOT_FOUND',
  29. 'message': "The resource 'projects/p' was not found"}]}
  30. # Scripted outcomes for successive set operations.
  31. APPLY = 'apply' # the write lands and the fingerprint moves on
  32. LOST = 'lost' # reported DONE, but nothing changed
  33. CONFLICT = 'conflict' # CONDITION_NOT_MET, nothing changed
  34. class _Call:
  35. def __init__(self, result):
  36. self._result = result
  37. def execute(self):
  38. return self._result
  39. class _FakeCompute:
  40. """A compute client holding one project metadata document.
  41. ``projects().get()`` returns a copy of the document; each
  42. ``setCommonInstanceMetadata()`` yields an operation whose fate is the
  43. next entry in ``outcomes``, resolved when ``wait_for_operation`` polls
  44. ``globalOperations().get()``.
  45. """
  46. def __init__(self, outcomes, items=None):
  47. self.outcomes = list(outcomes)
  48. self.document = {'fingerprint': 'fp-0',
  49. 'items': list(items or [])}
  50. self.version = 0
  51. self.fetches = 0
  52. self.saved_bodies = []
  53. self._pending = {}
  54. def projects(self):
  55. return self
  56. def globalOperations(self):
  57. return self
  58. def get(self, project, operation=None):
  59. if operation is not None:
  60. return self._resolve(operation)
  61. self.fetches += 1
  62. return _Call({'commonInstanceMetadata': {
  63. 'fingerprint': self.document['fingerprint'],
  64. 'items': [dict(i) for i in self.document['items']]}})
  65. def setCommonInstanceMetadata(self, project, body):
  66. self.saved_bodies.append(body)
  67. name = 'op-%d' % len(self.saved_bodies)
  68. self._pending[name] = body
  69. return _Call({'name': name})
  70. def _resolve(self, name):
  71. body = self._pending.pop(name)
  72. outcome = self.outcomes.pop(0)
  73. result = {'status': 'DONE'}
  74. if outcome == APPLY:
  75. self.version += 1
  76. self.document = {'fingerprint': 'fp-%d' % self.version,
  77. 'items': [dict(i) for i in body['items']]}
  78. elif outcome == CONFLICT:
  79. result['error'] = FINGERPRINT_CONFLICT
  80. elif outcome == LOST:
  81. pass
  82. else:
  83. result['error'] = outcome
  84. return _Call(result)
  85. def keys(self):
  86. return {i['key']: i['value'] for i in self.document['items']}
  87. class _FakeProvider:
  88. project_name = 'p'
  89. wait_for_operation = GCPCloudProvider.wait_for_operation
  90. def __init__(self, outcomes, items=None):
  91. self.gcp_compute = _FakeCompute(outcomes, items)
  92. class GCPMetadataSaveTestCase(unittest.TestCase):
  93. def setUp(self):
  94. # The production wait between attempts is exponential backoff; these
  95. # tests are about whether a retry happens, not how long it waits.
  96. patcher = mock.patch.object(gcp_metadata_save_op.retry, 'wait',
  97. tenacity.wait_none())
  98. patcher.start()
  99. self.addCleanup(patcher.stop)
  100. def test_fingerprint_conflict_is_retried_with_fresh_metadata(self):
  101. provider = _FakeProvider([CONFLICT, APPLY])
  102. callback = mock.Mock(side_effect=lambda md: md['items'].append(
  103. {'key': 'k', 'value': 'v'}))
  104. gcp_metadata_save_op(provider, callback)
  105. # Two attempts, each on metadata fetched anew so the retry carries
  106. # the fingerprint the conflict invalidated.
  107. self.assertEqual(callback.call_count, 2)
  108. self.assertEqual(
  109. [b['fingerprint'] for b in provider.gcp_compute.saved_bodies],
  110. ['fp-0', 'fp-0'])
  111. self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
  112. def test_write_reported_done_but_absent_is_reapplied(self):
  113. # The live suite showed operations completing as DONE with the
  114. # change missing from the document. A write is only finished once
  115. # it can be read back.
  116. provider = _FakeProvider([LOST, APPLY])
  117. modify_or_add_metadata_item(provider, 'k', 'v')
  118. self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
  119. self.assertEqual(provider.gcp_compute.keys(), {'k': 'v'})
  120. def test_write_that_never_lands_is_reported_after_retries(self):
  121. provider = _FakeProvider([LOST] * 10)
  122. with self.assertRaises(MetadataWriteNotApplied) as raised:
  123. modify_or_add_metadata_item(provider, 'k', 'v')
  124. self.assertIn('k', str(raised.exception))
  125. self.assertEqual(len(provider.gcp_compute.saved_bodies), 10)
  126. def test_other_operation_failures_are_raised_as_typed_errors(self):
  127. provider = _FakeProvider([OTHER_FAILURE])
  128. callback = mock.Mock(side_effect=lambda md: md['items'].append(
  129. {'key': 'k', 'value': 'v'}))
  130. with self.assertRaises(GCPOperationError) as raised:
  131. gcp_metadata_save_op(provider, callback)
  132. self.assertEqual(callback.call_count, 1)
  133. self.assertEqual(raised.exception.codes, ['RESOURCE_NOT_FOUND'])
  134. self.assertIn("was not found", str(raised.exception))
  135. def test_a_write_that_changes_nothing_is_not_sent(self):
  136. # Every set re-uploads the whole document and moves the fingerprint,
  137. # so a no-op (removing an absent key, re-setting the current value)
  138. # would only add contention for other writers.
  139. provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
  140. self.assertFalse(remove_metadata_item(provider, 'absent'))
  141. modify_or_add_metadata_item(provider, 'k', 'v')
  142. self.assertEqual(provider.gcp_compute.saved_bodies, [])
  143. def test_remove_is_reapplied_until_the_key_is_gone(self):
  144. provider = _FakeProvider([LOST, APPLY],
  145. items=[{'key': 'k', 'value': 'v'}])
  146. self.assertTrue(remove_metadata_item(provider, 'k'))
  147. self.assertEqual(len(provider.gcp_compute.saved_bodies), 2)
  148. self.assertEqual(provider.gcp_compute.keys(), {})
  149. def test_add_is_satisfied_by_its_own_value_already_present(self):
  150. # A retry must not append a second copy if the earlier attempt did
  151. # land after all.
  152. provider = _FakeProvider([], items=[{'key': 'k', 'value': 'v'}])
  153. add_metadata_item(provider, 'k', 'v')
  154. self.assertEqual(provider.gcp_compute.saved_bodies, [])
  155. def test_add_rejects_a_key_someone_else_holds(self):
  156. provider = _FakeProvider([], items=[{'key': 'k', 'value': 'theirs'}])
  157. with self.assertRaises(DuplicateResourceException):
  158. add_metadata_item(provider, 'k', 'mine')
  159. self.assertEqual(provider.gcp_compute.saved_bodies, [])