diff --git a/examples/wait/wait_example.py b/examples/wait/wait_example.py new file mode 100644 index 000000000..bc7b10988 --- /dev/null +++ b/examples/wait/wait_example.py @@ -0,0 +1,52 @@ +import os +from http import HTTPStatus + +from stackit.core.configuration import Configuration +from stackit.core.wait import WaitConfig +from stackit.dns.api.default_api import DefaultApi +from stackit.dns.models.create_zone_payload import CreateZonePayload +from stackit.dns.wait import wait_for_create_zone + +project_id = os.getenv("PROJECT_ID") + +# Create a new API client, that uses default authentication and configuration +config = Configuration() +client = DefaultApi(config) + + +# Create a new DNS zone +create_zone_response = client.create_zone( + create_zone_payload=CreateZonePayload(name="myZone", dnsName="testZone.com"), project_id=project_id +) +zone1_id = create_zone_response.zone.id +zone2_id = client.create_zone( + create_zone_payload=CreateZonePayload(name="myZone2", dnsName="testZone2.com"), project_id=project_id +).zone.id +zone3_id = client.create_zone( + create_zone_payload=CreateZonePayload(name="myZone3", dnsName="testZone3.com"), project_id=project_id +).zone.id + +# Wait for the zone to be fully created. +wait_for_zone_response = wait_for_create_zone(client, project_id, zone1_id) + +# Optionally the wait configuration can be adjusted. It's possible to adjust just one of the parameters.... +wait_for_zone_response = wait_for_create_zone(client, project_id, zone2_id, WaitConfig(sleep_before_wait=5)) + +# ... or all of them +wait_for_zone_response = wait_for_create_zone( + client, + project_id, + zone3_id, + WaitConfig( + sleep_before_wait=6, + throttle=10, + timeout=10, + temp_error_retry_limit=10, + retry_http_error_status_codes=[HTTPStatus.BAD_GATEWAY, HTTPStatus.GATEWAY_TIMEOUT], + ), +) + +# Delete all of the zones again +delete_zone_message = client.delete_zone(project_id, zone1_id) +delete_zone_message = client.delete_zone(project_id, zone2_id) +delete_zone_message = client.delete_zone(project_id, zone3_id) diff --git a/services/dns/pyproject.toml b/services/dns/pyproject.toml index 8a0cd6c60..d85517951 100644 --- a/services/dns/pyproject.toml +++ b/services/dns/pyproject.toml @@ -18,7 +18,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.8,<4.0" -stackit-core = "^0.0.1a" +stackit-core = "0.0.1a1" requests = "^2.32.3" pydantic = "^2.9.2" python-dateutil = "^2.9.0.post0" diff --git a/services/dns/src/stackit/dns/wait.py b/services/dns/src/stackit/dns/wait.py new file mode 100644 index 000000000..e44baba7c --- /dev/null +++ b/services/dns/src/stackit/dns/wait.py @@ -0,0 +1,219 @@ +from enum import Enum +from typing import Any, Tuple, Union + +from stackit.core.wait import Wait, WaitConfig + +from stackit.dns.api.default_api import DefaultApi +from stackit.dns.exceptions import ApiException +from stackit.dns.models.record_set_response import RecordSetResponse +from stackit.dns.models.zone_response import ZoneResponse + + +class _States(str, Enum): + CreateSuccess = "CREATE_SUCCEEDED" + CreateFail = "CREATE_FAILED" + UpdateSuccess = "UPDATE_SUCCEEDED" + UpdateFail = "UPDATE_FAILED" + DeleteSuccess = "DELETE_SUCCEEDED" + DeleteFail = "DELETE_FAILED" + + +def wait_for_create_zone( + api_client: DefaultApi, + project_id: str, + zone_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> ZoneResponse: + + def get_zone_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id + + try: + response = api_client.get_zone(project_id, zone_id) + if response.zone.id != zone_id: + return False, ValueError("ID of zone in return not equal to ID of requested zone."), None, None + elif response.zone.state == _States.CreateSuccess: + return True, None, None, response + elif response.zone.state == _States.CreateFail: + return True, Exception("Create failed for zone with ID %s" % zone_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_zone_execute_state, + config=wait_config, + ) + return wait.wait() + + +def wait_for_partial_update_zone( + api_client: DefaultApi, + project_id: str, + zone_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> ZoneResponse: + + def get_zone_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id + + try: + response = api_client.get_zone(project_id, zone_id) + if response.zone.id != zone_id: + return False, ValueError("ID of zone in return not equal to ID of requested zone."), None, None + elif response.zone.state == _States.UpdateSuccess: + return True, None, None, response + elif response.zone.state == _States.UpdateFail: + return True, Exception("Update failed for zone with ID %s" % zone_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_zone_execute_state, + config=wait_config, + ) + return wait.wait() + + +def wait_for_delete_zone( + api_client: DefaultApi, + project_id: str, + zone_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> ZoneResponse: + + def get_zone_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id + + try: + response = api_client.get_zone(project_id, zone_id) + if response.zone.id != zone_id: + return False, ValueError("ID of zone in return not equal to ID of requested zone."), None, None + elif response.zone.state == _States.DeleteSuccess: + return True, None, None, response + elif response.zone.state == _States.DeleteFail: + return True, Exception("Delete failed for zone with ID %s" % zone_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_zone_execute_state, + config=wait_config, + ) + return wait.wait() + + +def wait_for_create_recordset( + api_client: DefaultApi, + project_id: str, + zone_id: str, + rr_set_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> RecordSetResponse: + + def get_rr_set_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id, rr_set_id + + try: + response = api_client.get_record_set(project_id, zone_id, rr_set_id) + if response.rrset.id != rr_set_id: + return False, ValueError("ID of rrset in return not equal to ID of requested rrset."), None, None + elif response.rrset.state == _States.CreateSuccess: + return True, None, None, response + elif response.rrset.state == _States.CreateFail: + return True, Exception("Create failed for rrset with ID %s" % rr_set_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_rr_set_execute_state, + config=wait_config, + ) + return wait.wait() + + +def wait_for_partial_update_recordset( + api_client: DefaultApi, + project_id: str, + zone_id: str, + rr_set_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> RecordSetResponse: + + def get_rr_set_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id, rr_set_id + + try: + response = api_client.get_record_set(project_id, zone_id, rr_set_id) + if response.rrset.id != rr_set_id: + return False, ValueError("ID of rrset in return not equal to ID of requested rrset."), None, None + elif response.rrset.state == _States.UpdateSuccess: + return True, None, None, response + elif response.rrset.state == _States.UpdateFail: + return True, Exception("Update failed for rrset with ID %s" % rr_set_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_rr_set_execute_state, + config=wait_config, + ) + return wait.wait() + + +def wait_for_delete_recordset( + api_client: DefaultApi, + project_id: str, + zone_id: str, + rr_set_id: str, + wait_config: Union[WaitConfig, None] = None, +) -> RecordSetResponse: + + def get_rr_set_execute_state() -> Tuple[bool, Union[Exception, None], Union[int, None], Any]: + + nonlocal api_client, project_id, zone_id, rr_set_id + + try: + response = api_client.get_record_set(project_id, zone_id, rr_set_id) + if response.rrset.id != rr_set_id: + return False, ValueError("ID of rrset in return not equal to ID of requested rrset."), None, None + elif response.rrset.state == _States.DeleteSuccess: + return True, None, None, response + elif response.rrset.state == _States.DeleteFail: + return True, Exception("Delete failed for rrset with ID %s" % rr_set_id), None, response + else: + return False, None, None, None + except ApiException as e: + return False, e, e.status, None + except Exception as e: + return False, e, None, None + + wait = Wait( + get_rr_set_execute_state, + config=wait_config, + ) + return wait.wait() diff --git a/services/dns/tests/test_wait.py b/services/dns/tests/test_wait.py new file mode 100644 index 000000000..8cfc6cac0 --- /dev/null +++ b/services/dns/tests/test_wait.py @@ -0,0 +1,383 @@ +from typing import List +from unittest.mock import Mock + +import pytest + +from stackit.dns.models.record import Record +from stackit.dns.models.record_set import RecordSet +from stackit.dns.models.record_set_response import RecordSetResponse +from stackit.dns.models.zone import Zone +from stackit.dns.models.zone_response import ZoneResponse +from stackit.dns.wait import * + + +def zone_response(state: str, zone_id: str) -> ZoneResponse: + return ZoneResponse( + zone=Zone( + id=zone_id, + acl="acl", + creationFinished="creationFinished", + creationStarted="creationStarted", + defaultTTL=60, + dnsName="dnsName", + expireTime=60, + name="name", + negativeCache=60, + primaryNameServer="fqdn", + refreshTime=60, + retryTime=60, + serialNumber=123456, + state=state, + type="primary", + updateFinished="updateFinished", + updateStarted="updateStarted", + visibility="public", + ) + ) + + +def rr_set_response(state: str, rr_set_id: str) -> ZoneResponse: + return RecordSetResponse( + rrset=RecordSet( + creationFinished="creationFinished", + creationStarted="creationStarted", + id=rr_set_id, + name="name", + records=[Record(id="id", content="content")], + state=state, + ttl=60, + type="A", + updateFinished="updateFinished", + updateStarted="updateStarted", + ) + ) + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("CREATING", "incorrect_zone_id"))] +) +def test_create_zone_wait_handler_fails_for_wrong_id(zone_id: str, project_id: str, api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(ValueError, match="ID of zone in return not equal to ID of requested zone."): + wait_for_create_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,expected_api_response", + [("zone_id", "project_id", zone_response("CREATE_SUCCEEDED", "zone_id"))], +) +def test_create_zone_wait_handler_retuns_response(zone_id: str, project_id: str, expected_api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = expected_api_response + + response = wait_for_create_zone(api_client, project_id, zone_id) + + assert response == expected_api_response + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("CREATE_FAILED", "zone_id"))] +) +def test_create_zone_wait_handler_fails_for_failure(zone_id: str, project_id: str, api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(Exception, match=f"Create failed for zone with ID {zone_id}"): + wait_for_create_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,api_responses", + [ + ( + "zone_id", + "project_id", + [ + zone_response("CREATING", "zone_id"), + zone_response("CREATING", "zone_id"), + zone_response("CREATE_SUCCEEDED", "zone_id"), + ], + ) + ], +) +def test_create_zone_wait_handler_waits(zone_id: str, project_id: str, api_responses: List[ZoneResponse]): + api_client = Mock() + api_client.get_zone.side_effect = api_responses + + assert wait_for_create_zone(api_client, project_id, zone_id) == api_responses[-1] + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("UPDATING", "incorrect_zone_id"))] +) +def test_wait_for_partial_update_zone_fails_for_wrong_id(zone_id: str, project_id: str, api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(ValueError, match="ID of zone in return not equal to ID of requested zone."): + wait_for_partial_update_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,expected_api_response", + [("zone_id", "project_id", zone_response("UPDATE_SUCCEEDED", "zone_id"))], +) +def test_wait_for_partial_update_zone_handler_retuns_response( + zone_id: str, project_id: str, expected_api_response: ZoneResponse +): + api_client = Mock() + api_client.get_zone.return_value = expected_api_response + + response = wait_for_partial_update_zone(api_client, project_id, zone_id) + + assert response == expected_api_response + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("UPDATE_FAILED", "zone_id"))] +) +def test_wait_for_partial_update_zone_handler_fails_for_failure( + zone_id: str, project_id: str, api_response: ZoneResponse +): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(Exception, match=f"Update failed for zone with ID {zone_id}"): + wait_for_partial_update_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,api_responses", + [ + ( + "zone_id", + "project_id", + [ + zone_response("UPDATING", "zone_id"), + zone_response("UPDATING", "zone_id"), + zone_response("UPDATE_SUCCEEDED", "zone_id"), + ], + ) + ], +) +def test_wait_for_partial_update_zone_waits(zone_id: str, project_id: str, api_responses: List[ZoneResponse]): + api_client = Mock() + api_client.get_zone.side_effect = api_responses + + assert wait_for_partial_update_zone(api_client, project_id, zone_id) == api_responses[-1] + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("DELETING", "incorrect_zone_id"))] +) +def test_wait_for_delete_zone_fails_for_wrong_id(zone_id: str, project_id: str, api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(ValueError, match="ID of zone in return not equal to ID of requested zone."): + wait_for_delete_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,expected_api_response", + [("zone_id", "project_id", zone_response("DELETE_SUCCEEDED", "zone_id"))], +) +def test_wait_for_delete_zone_handler_retuns_response( + zone_id: str, project_id: str, expected_api_response: ZoneResponse +): + api_client = Mock() + api_client.get_zone.return_value = expected_api_response + + response = wait_for_delete_zone(api_client, project_id, zone_id) + + assert response == expected_api_response + + +@pytest.mark.parametrize( + "zone_id,project_id,api_response", [("zone_id", "project_id", zone_response("DELETE_FAILED", "zone_id"))] +) +def test_wait_for_delete_zone_handler_fails_for_failure(zone_id: str, project_id: str, api_response: ZoneResponse): + api_client = Mock() + api_client.get_zone.return_value = api_response + + with pytest.raises(Exception, match=f"Delete failed for zone with ID {zone_id}"): + wait_for_delete_zone(api_client, project_id, zone_id) + + +@pytest.mark.parametrize( + "zone_id,project_id,api_responses", + [ + ( + "zone_id", + "project_id", + [ + zone_response("DELETING", "zone_id"), + zone_response("DELETING", "zone_id"), + zone_response("DELETE_SUCCEEDED", "zone_id"), + ], + ) + ], +) +def test_wait_for_delete_zone_zone_waits(zone_id: str, project_id: str, api_responses: List[ZoneResponse]): + api_client = Mock() + api_client.get_zone.side_effect = api_responses + + assert wait_for_delete_zone(api_client, project_id, zone_id) == api_responses[-1] + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("CREATING", "incorrect_rr_set_id"))], +) +def test_wait_for_create_recordset_fails_for_wrong_id( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(ValueError, match="ID of rrset in return not equal to ID of requested rrset."): + wait_for_create_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("CREATE_FAILED", "rr_set_id"))], +) +def test_wait_for_create_recordset_fails_for_failure( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(Exception, match=f"Create failed for rrset with ID {rr_set_id}"): + wait_for_create_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_responses", + [ + ( + "rr_set_id", + "zone_id", + "project_id", + [ + rr_set_response("CREATING", "rr_set_id"), + rr_set_response("CREATING", "rr_set_id"), + rr_set_response("CREATE_SUCCEEDED", "rr_set_id"), + ], + ) + ], +) +def test_wait_for_create_recordset_waits( + rr_set_id: str, zone_id: str, project_id: str, api_responses: List[RecordSetResponse] +): + api_client = Mock() + api_client.get_record_set.side_effect = api_responses + + assert wait_for_create_recordset(api_client, project_id, zone_id, rr_set_id) == api_responses[-1] + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("UPDATING", "incorrect_rr_set_id"))], +) +def test_wait_for_partial_update_recordset_fails_for_wrong_id( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(ValueError, match="ID of rrset in return not equal to ID of requested rrset."): + wait_for_partial_update_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("UPDATE_FAILED", "rr_set_id"))], +) +def test_wait_for_partial_update_recordset_fails_for_failure( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(Exception, match=f"Update failed for rrset with ID {rr_set_id}"): + wait_for_partial_update_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_responses", + [ + ( + "rr_set_id", + "zone_id", + "project_id", + [ + rr_set_response("UPDATING", "rr_set_id"), + rr_set_response("UPDATING", "rr_set_id"), + rr_set_response("UPDATE_SUCCEEDED", "rr_set_id"), + ], + ) + ], +) +def test_wait_for_partial_update_recordset_waits( + rr_set_id: str, zone_id: str, project_id: str, api_responses: List[RecordSetResponse] +): + api_client = Mock() + api_client.get_record_set.side_effect = api_responses + + assert wait_for_partial_update_recordset(api_client, project_id, zone_id, rr_set_id) == api_responses[-1] + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("DELETING", "incorrect_rr_set_id"))], +) +def test_wait_for_delete_recordset_fails_for_wrong_id( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(ValueError, match="ID of rrset in return not equal to ID of requested rrset."): + wait_for_delete_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_response", + [("rr_set_id", "zone_id", "project_id", rr_set_response("DELETE_FAILED", "rr_set_id"))], +) +def test_wait_for_delete_recordset_fails_for_failure( + rr_set_id: str, zone_id: str, project_id: str, api_response: RecordSetResponse +): + api_client = Mock() + api_client.get_record_set.return_value = api_response + + with pytest.raises(Exception, match=f"Delete failed for rrset with ID {rr_set_id}"): + wait_for_delete_recordset(api_client, project_id, zone_id, rr_set_id) + + +@pytest.mark.parametrize( + "rr_set_id,zone_id,project_id,api_responses", + [ + ( + "rr_set_id", + "zone_id", + "project_id", + [ + rr_set_response("DELETING", "rr_set_id"), + rr_set_response("DELETING", "rr_set_id"), + rr_set_response("DELETE_SUCCEEDED", "rr_set_id"), + ], + ) + ], +) +def test_wait_for_delete_recordset_waits( + rr_set_id: str, zone_id: str, project_id: str, api_responses: List[RecordSetResponse] +): + api_client = Mock() + api_client.get_record_set.side_effect = api_responses + + assert wait_for_delete_recordset(api_client, project_id, zone_id, rr_set_id) == api_responses[-1]