test_gcp_metadata_save.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """Retrying GCP common-metadata writes on a fingerprint conflict.
  2. GCP keeps labels and key pairs in the project-wide common instance metadata,
  3. which every write re-uploads under an optimistic fingerprint. A concurrent
  4. writer makes the upload's *operation* fail with ``CONDITION_NOT_MET``; that
  5. is a different path from an HTTP-level error, and the write has to be retried
  6. with freshly fetched metadata on either. No SDK is involved: the compute
  7. client is a fake and the provider's real ``wait_for_operation`` polls it.
  8. """
  9. import unittest
  10. from unittest import mock
  11. import tenacity
  12. from cloudbridge.providers.gcp.helpers import GCPOperationError
  13. from cloudbridge.providers.gcp.helpers import gcp_metadata_save_op
  14. from cloudbridge.providers.gcp.provider import GCPCloudProvider
  15. FINGERPRINT_CONFLICT = {
  16. 'errors': [{'code': 'CONDITION_NOT_MET',
  17. 'message': 'Supplied fingerprint does not match current '
  18. 'metadata fingerprint.'}]}
  19. OTHER_FAILURE = {
  20. 'errors': [{'code': 'RESOURCE_NOT_FOUND',
  21. 'message': "The resource 'projects/p' was not found"}]}
  22. class _Call:
  23. def __init__(self, result):
  24. self._result = result
  25. def execute(self):
  26. return self._result
  27. class _FakeCompute:
  28. """Enough of the compute client for a metadata save: each save yields an
  29. operation whose outcome is the next entry in ``operation_results``."""
  30. def __init__(self, operation_results):
  31. self.operation_results = list(operation_results)
  32. self.fetches = 0
  33. self.saved_bodies = []
  34. # projects().get() / projects().setCommonInstanceMetadata()
  35. def projects(self):
  36. return self
  37. def get(self, project):
  38. self.fetches += 1
  39. return _Call({'commonInstanceMetadata': {
  40. 'fingerprint': f'fp-{self.fetches}', 'items': []}})
  41. def setCommonInstanceMetadata(self, project, body):
  42. self.saved_bodies.append(body)
  43. return _Call({'name': f'op-{len(self.saved_bodies)}'})
  44. # globalOperations().get() - polled by wait_for_operation
  45. def globalOperations(self):
  46. return self
  47. def get_operation(self, project, operation):
  48. outcome = self.operation_results.pop(0)
  49. result = {'status': 'DONE'}
  50. if outcome is not None:
  51. result['error'] = outcome
  52. return _Call(result)
  53. class _FakeProvider:
  54. project_name = 'p'
  55. wait_for_operation = GCPCloudProvider.wait_for_operation
  56. def __init__(self, operation_results):
  57. self.gcp_compute = _FakeCompute(operation_results)
  58. # wait_for_operation calls operations.get(**args); the fake's
  59. # get() is taken by projects().get(project=), so route it.
  60. self.gcp_compute.get = self._route_get
  61. def _route_get(self, **kwargs):
  62. if 'operation' in kwargs:
  63. return self.gcp_compute.get_operation(**kwargs)
  64. return _FakeCompute.get(self.gcp_compute, **kwargs)
  65. def _save(provider, callback):
  66. # The production wait between attempts is exponential backoff; the test
  67. # is about whether a retry happens, not how long it waits.
  68. return gcp_metadata_save_op.retry_with(
  69. wait=tenacity.wait_none())(provider, callback)
  70. class GCPMetadataSaveTestCase(unittest.TestCase):
  71. def test_fingerprint_conflict_is_retried_with_fresh_metadata(self):
  72. provider = _FakeProvider([FINGERPRINT_CONFLICT, None])
  73. callback = mock.Mock()
  74. _save(provider, callback)
  75. # Two attempts, each on metadata fetched anew so the retry carries
  76. # the fingerprint the conflict invalidated.
  77. self.assertEqual(provider.gcp_compute.fetches, 2)
  78. self.assertEqual(callback.call_count, 2)
  79. self.assertEqual(
  80. [body['fingerprint'] for body in provider.gcp_compute.saved_bodies],
  81. ['fp-1', 'fp-2'])
  82. def test_other_operation_failures_are_raised_as_typed_errors(self):
  83. provider = _FakeProvider([OTHER_FAILURE])
  84. callback = mock.Mock()
  85. with self.assertRaises(GCPOperationError) as raised:
  86. _save(provider, callback)
  87. self.assertEqual(callback.call_count, 1)
  88. self.assertEqual(raised.exception.codes, ['RESOURCE_NOT_FOUND'])
  89. self.assertIn("was not found", str(raised.exception))