|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | + |
| 3 | +# Copyright (c) 2025 Felix Fontein <[email protected]> |
| 4 | +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) |
| 5 | +# SPDX-License-Identifier: BSD-2-Clause |
| 6 | + |
| 7 | +from __future__ import absolute_import, division, print_function |
| 8 | +__metaclass__ = type |
| 9 | + |
| 10 | + |
| 11 | +from ansible.module_utils.six import PY3 |
| 12 | +from ansible.module_utils.six.moves.urllib.parse import urlencode |
| 13 | +from ansible.module_utils.urls import fetch_url |
| 14 | + |
| 15 | +import time |
| 16 | + |
| 17 | +from ansible_collections.community.hrobot.plugins.module_utils.common import ( # pylint: disable=unused-import |
| 18 | + CheckDoneTimeoutException, |
| 19 | +) |
| 20 | + |
| 21 | + |
| 22 | +API_DEFAULT_ARGUMENT_SPEC = dict( |
| 23 | + hetzner_token=dict(type='str', required=True, no_log=True), |
| 24 | + rate_limit_retry_timeout=dict(type='int', default=-1), |
| 25 | +) |
| 26 | + |
| 27 | +_API_DEFAULT_ARGUMENT_SPEC_COMPAT = dict( |
| 28 | + hetzner_token=dict(type='str', required=False, no_log=True), |
| 29 | +) |
| 30 | + |
| 31 | +# The API endpoint is fixed. |
| 32 | +API_BASE_URL = "https://api.hetzner.com" |
| 33 | + |
| 34 | + |
| 35 | +_RATE_LIMITING_ERROR = 'rate_limit_exceeded' |
| 36 | +_RATE_LIMITING_START_DELAY = 5 |
| 37 | + |
| 38 | + |
| 39 | +def format_api_error_msg(error, rate_limit_timeout=None): |
| 40 | + # Reference: https://docs.hetzner.cloud/reference/hetzner#errors |
| 41 | + msg = 'Request failed: [{0}] {1}'.format( |
| 42 | + error['code'], |
| 43 | + error['message'], |
| 44 | + ) |
| 45 | + if error.get('details'): |
| 46 | + msg += ". Details: {0}".format(error['details']) |
| 47 | + return msg |
| 48 | + |
| 49 | + |
| 50 | +def raw_api_fetch_url_json( |
| 51 | + module, |
| 52 | + url, |
| 53 | + method='GET', |
| 54 | + timeout=10, |
| 55 | + data=None, |
| 56 | + headers=None, |
| 57 | + accept_errors=None, |
| 58 | + # allow_empty_result=False, |
| 59 | + # allowed_empty_result_status_codes=(), |
| 60 | + rate_limit_timeout=None, |
| 61 | +): |
| 62 | + ''' |
| 63 | + Make general request to Hetzner's API. |
| 64 | + Does not handle rate limiting especially. |
| 65 | + ''' |
| 66 | + actual_headers = { |
| 67 | + "Authorization": "Bearer {0}".format(module.params['hetzner_token']), |
| 68 | + } |
| 69 | + if headers: |
| 70 | + actual_headers.update(headers) |
| 71 | + accept_errors = accept_errors or () |
| 72 | + |
| 73 | + resp, info = fetch_url(module, url, method=method, timeout=timeout, data=data, headers=actual_headers) |
| 74 | + try: |
| 75 | + # In Python 2, reading from a closed response yields a TypeError. |
| 76 | + # In Python 3, read() simply returns '' |
| 77 | + if PY3 and resp.closed: |
| 78 | + raise TypeError |
| 79 | + content = resp.read() |
| 80 | + except (AttributeError, TypeError): |
| 81 | + content = info.pop('body', None) |
| 82 | + |
| 83 | + if not content: |
| 84 | + # if allow_empty_result and info.get('status') in allowed_empty_result_status_codes: |
| 85 | + # return None, info, None |
| 86 | + module.fail_json( |
| 87 | + msg='Cannot retrieve content from {0} {1}, HTTP status code {2} ({3})'.format( |
| 88 | + method, url, info.get('status'), info.get('msg') |
| 89 | + ) |
| 90 | + ) |
| 91 | + |
| 92 | + try: |
| 93 | + result = module.from_json(content.decode('utf8')) |
| 94 | + if 'error' in result: |
| 95 | + if result['error']['code'] in accept_errors: |
| 96 | + return result, info, result['error']['code'] |
| 97 | + module.fail_json( |
| 98 | + msg=format_api_error_msg(result['error'], rate_limit_timeout=rate_limit_timeout), |
| 99 | + error=result['error'], |
| 100 | + ) |
| 101 | + return result, info, None |
| 102 | + except ValueError: |
| 103 | + module.fail_json(msg='Cannot decode content retrieved from {0}'.format(url)) |
| 104 | + |
| 105 | + |
| 106 | +def _handle_rate_limit(accept_errors, check_done_timeout, call): |
| 107 | + original_accept_errors, accept_errors = accept_errors, accept_errors or () |
| 108 | + check_done_delay = _RATE_LIMITING_START_DELAY |
| 109 | + if _RATE_LIMITING_ERROR in accept_errors or check_done_timeout == 0: |
| 110 | + return call(original_accept_errors, None) |
| 111 | + accept_errors = [_RATE_LIMITING_ERROR] + list(accept_errors) |
| 112 | + |
| 113 | + start_time = time.time() |
| 114 | + first = True |
| 115 | + timeout = False |
| 116 | + while True: |
| 117 | + if first: |
| 118 | + elapsed = 0 |
| 119 | + first = False |
| 120 | + else: |
| 121 | + elapsed = (time.time() - start_time) |
| 122 | + if check_done_timeout > 0: |
| 123 | + left_time = check_done_timeout - elapsed |
| 124 | + wait = max(min(check_done_delay, left_time), 0) |
| 125 | + timeout = left_time <= check_done_delay |
| 126 | + else: |
| 127 | + wait = check_done_delay |
| 128 | + time.sleep(wait) |
| 129 | + result, info, error = call( |
| 130 | + original_accept_errors if timeout else accept_errors, |
| 131 | + elapsed, |
| 132 | + ) |
| 133 | + if error != _RATE_LIMITING_ERROR: |
| 134 | + return result, info, error |
| 135 | + # TODO: is there a hint how much time we should wait? |
| 136 | + # If yes, adjust check_done_delay accordingly! |
| 137 | + |
| 138 | + |
| 139 | +def api_fetch_url_json( |
| 140 | + module, |
| 141 | + url, |
| 142 | + method='GET', |
| 143 | + timeout=10, |
| 144 | + data=None, |
| 145 | + headers=None, |
| 146 | + accept_errors=None, |
| 147 | + # allow_empty_result=False, |
| 148 | + # allowed_empty_result_status_codes=(), |
| 149 | +): |
| 150 | + ''' |
| 151 | + Make general request to Hetzner's API. |
| 152 | + ''' |
| 153 | + def call(accept_errors_, rate_limit_timeout): |
| 154 | + return raw_api_fetch_url_json( |
| 155 | + module, |
| 156 | + url, |
| 157 | + method=method, |
| 158 | + timeout=timeout, |
| 159 | + data=data, |
| 160 | + headers=headers, |
| 161 | + accept_errors=accept_errors_, |
| 162 | + # allow_empty_result=allow_empty_result, |
| 163 | + # allowed_empty_result_status_codes=allowed_empty_result_status_codes, |
| 164 | + rate_limit_timeout=rate_limit_timeout, |
| 165 | + ) |
| 166 | + |
| 167 | + return _handle_rate_limit( |
| 168 | + accept_errors, |
| 169 | + module.params['rate_limit_retry_timeout'], |
| 170 | + call, |
| 171 | + ) |
| 172 | + |
| 173 | + |
| 174 | +def deterministic_urlencode(data, **kwargs): |
| 175 | + """ |
| 176 | + Same as urlencode(), but the keys are sorted lexicographically. |
| 177 | + """ |
| 178 | + result = [] |
| 179 | + for key, value in sorted(data.items()): |
| 180 | + result.append(urlencode({key: value}, **kwargs)) |
| 181 | + return '&'.join(result) |
| 182 | + |
| 183 | + |
| 184 | +def api_fetch_url_json_list( |
| 185 | + module, |
| 186 | + url, |
| 187 | + data_key, |
| 188 | + method='GET', |
| 189 | + timeout=10, |
| 190 | + headers=None, |
| 191 | + page_size=100, |
| 192 | +): |
| 193 | + ''' |
| 194 | + Completely request a paginated list from Hetzner's API. |
| 195 | + ''' |
| 196 | + page = 1 |
| 197 | + last_page = None |
| 198 | + result_list = [] |
| 199 | + while page is not None and (last_page is None or last_page >= page): |
| 200 | + page_url = '{0}{1}{2}'.format(url, '&' if '?' in url else '?', deterministic_urlencode({"page": str(page), "per_page": page_size})) |
| 201 | + result, dummy, dummy2 = api_fetch_url_json( |
| 202 | + module, |
| 203 | + page_url, |
| 204 | + method=method, |
| 205 | + timeout=timeout, |
| 206 | + headers=headers, |
| 207 | + ) |
| 208 | + if isinstance(result.get(data_key), list): |
| 209 | + result_list += result[data_key] |
| 210 | + if isinstance(result.get("meta"), dict) and isinstance(result["meta"].get("pagination"), dict): |
| 211 | + pagination = result["meta"]["pagination"] |
| 212 | + if isinstance(pagination.get("last_page"), int): |
| 213 | + last_page = pagination["last_page"] |
| 214 | + if isinstance(pagination.get("next_page"), int): |
| 215 | + page = pagination["next_page"] |
| 216 | + else: |
| 217 | + page += 1 |
| 218 | + elif not result.get(data_key): |
| 219 | + break |
| 220 | + else: |
| 221 | + page += 1 |
| 222 | + return result_list |
| 223 | + |
| 224 | + |
| 225 | +def api_fetch_url_json_with_retries(module, url, check_done_callback, check_done_delay=10, check_done_timeout=180, skip_first=False, **kwargs): |
| 226 | + ''' |
| 227 | + Make general request to Hetzner's API, with retries until a condition is satisfied. |
| 228 | +
|
| 229 | + The condition is tested by calling ``check_done_callback(result, error)``. If it is not satisfied, |
| 230 | + it will be retried with delays ``check_done_delay`` (in seconds) until a total timeout of |
| 231 | + ``check_done_timeout`` (in seconds) since the time the first request is started is reached. |
| 232 | +
|
| 233 | + If ``skip_first`` is specified, will assume that a first call has already been made and will |
| 234 | + directly start with waiting. |
| 235 | + ''' |
| 236 | + start_time = time.time() |
| 237 | + if not skip_first: # pragma: no cover |
| 238 | + raise AssertionError("Code path not yet available") # pragma: no cover |
| 239 | + # result, error = api_fetch_url_json(module, url, **kwargs) |
| 240 | + # if check_done_callback(result, error): |
| 241 | + # return result, error |
| 242 | + while True: |
| 243 | + elapsed = (time.time() - start_time) |
| 244 | + left_time = check_done_timeout - elapsed |
| 245 | + time.sleep(max(min(check_done_delay, left_time), 0)) |
| 246 | + result, info, error = api_fetch_url_json(module, url, **kwargs) |
| 247 | + if check_done_callback(result, info, error): |
| 248 | + return result, info, error |
| 249 | + if left_time < check_done_delay: |
| 250 | + raise CheckDoneTimeoutException(result, error) |
0 commit comments