test_aws_dns_waiters.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """
  2. Unit tests for how ``AWSDnsRecordService`` waits on Route53 changes.
  3. Creating or deleting a record blocks until Route53 reports the change INSYNC.
  4. boto3's ``resource_record_sets_changed`` waiter polls every 30 seconds by
  5. default, so a change that propagates in a few seconds still costs a full 30 --
  6. and a test that makes four record changes pays 120 seconds of pure sleep.
  7. Measured against real Route53, INSYNC was reached within the first poll
  8. interval every time, making the granularity the entire cost.
  9. These tests drive the real botocore waiter against a simulated clock, so they
  10. assert on how long we *would* sleep without actually sleeping.
  11. """
  12. import unittest
  13. from unittest import mock
  14. import botocore.client
  15. import botocore.waiter
  16. from botocore.exceptions import WaiterError
  17. from cloudbridge.providers.aws import AWSCloudProvider
  18. from cloudbridge.providers.aws.resources import AWSDnsRecord
  19. from cloudbridge.providers.aws.resources import AWSDnsZone
  20. from cloudbridge.providers.aws.services import AWSDnsRecordService
  21. # Simulated seconds before Route53 reports INSYNC. Real-world measurement put
  22. # this comfortably inside one 30s poll interval.
  23. INSYNC_AFTER = 6.0
  24. BOTO_DEFAULT_DELAY = 30.0
  25. # The waiter's ceiling must stay at roughly 30 minutes however it is polled.
  26. REQUIRED_CEILING = 1700.0
  27. ZONE = {'Id': '/hostedzone/Z1EXAMPLE', 'Name': 'example.com.'}
  28. RECORD = {'Name': 'sub.example.com.', 'Type': 'CNAME', 'TTL': 500,
  29. 'ResourceRecords': [{'Value': 'hello.com.'}]}
  30. class _Route53Sim:
  31. """Canned Route53 responses driven by a simulated clock."""
  32. def __init__(self, insync_after=INSYNC_AFTER):
  33. self.insync_after = insync_after
  34. self.clock = 0.0
  35. self.sleeps = []
  36. self.get_change_calls = 0
  37. def api(self, operation_name, params):
  38. if operation_name == 'ChangeResourceRecordSets':
  39. return {'ChangeInfo': {'Id': '/change/C1', 'Status': 'PENDING'}}
  40. if operation_name == 'GetChange':
  41. self.get_change_calls += 1
  42. status = ('INSYNC' if self.clock >= self.insync_after
  43. else 'PENDING')
  44. return {'ChangeInfo': {'Id': '/change/C1', 'Status': status}}
  45. if operation_name == 'ListResourceRecordSets':
  46. return {'ResourceRecordSets': [RECORD], 'IsTruncated': False}
  47. raise AssertionError('unexpected operation: ' + operation_name)
  48. def sleep(self, secs):
  49. self.sleeps.append(secs)
  50. self.clock += secs
  51. @property
  52. def total_wait(self):
  53. return sum(self.sleeps)
  54. def _provider():
  55. return AWSCloudProvider({'aws_access_key': 'dummy',
  56. 'aws_secret_key': 'dummy',
  57. 'aws_zone_name': 'us-east-1a'})
  58. def _run(sim, fn):
  59. """Run fn with Route53 stubbed and the waiter's clock simulated."""
  60. with mock.patch.object(botocore.client.BaseClient, '_make_api_call',
  61. lambda self, op, params: sim.api(op, params)), \
  62. mock.patch.object(botocore.waiter.time, 'sleep', sim.sleep):
  63. return fn()
  64. class AWSDnsWaiterTestCase(unittest.TestCase):
  65. def setUp(self):
  66. self.provider = _provider()
  67. self.svc = AWSDnsRecordService(self.provider)
  68. self.zone = AWSDnsZone(self.provider, ZONE)
  69. self.record = AWSDnsRecord(self.provider, self.zone, RECORD)
  70. def test_create_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
  71. sim = _Route53Sim()
  72. _run(sim, lambda: self.svc.create(
  73. self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
  74. self.assertLess(
  75. sim.total_wait, BOTO_DEFAULT_DELAY,
  76. "A change that went INSYNC after %ss cost %ss of sleep; the "
  77. "waiter is still polling at boto3's %ss default"
  78. % (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
  79. def test_delete_does_not_burn_a_full_poll_interval_on_a_fast_change(self):
  80. sim = _Route53Sim()
  81. _run(sim, lambda: self.svc.delete(self.zone, self.record))
  82. self.assertLess(
  83. sim.total_wait, BOTO_DEFAULT_DELAY,
  84. "A change that went INSYNC after %ss cost %ss of sleep; the "
  85. "waiter is still polling at boto3's %ss default"
  86. % (INSYNC_AFTER, sim.total_wait, BOTO_DEFAULT_DELAY))
  87. def test_waiter_polls_until_the_change_is_actually_insync(self):
  88. """Faster polling must not mean giving up early."""
  89. sim = _Route53Sim(insync_after=47.0)
  90. _run(sim, lambda: self.svc.create(
  91. self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
  92. self.assertGreaterEqual(sim.clock, 47.0,
  93. "Returned before the change was INSYNC")
  94. self.assertGreater(sim.get_change_calls, 1)
  95. def test_waiter_ceiling_is_still_about_thirty_minutes(self):
  96. """Polling more often must not shrink how long we are willing to
  97. wait -- a genuinely slow change should still be given ~30 minutes
  98. before the waiter gives up."""
  99. sim = _Route53Sim(insync_after=float('inf'))
  100. with self.assertRaises(WaiterError):
  101. _run(sim, lambda: self.svc.create(
  102. self.zone, 'sub.example.com.', 'CNAME', 'hello.com', ttl=500))
  103. self.assertGreaterEqual(
  104. sim.total_wait, REQUIRED_CEILING,
  105. "Waiter gave up after only %ss of simulated waiting"
  106. % sim.total_wait)
  107. if __name__ == '__main__':
  108. unittest.main()