Skip to content

Commit dafdd16

Browse files
authored
Implement basic Hetzner API support for storagebox and storagebox_info (#166)
* Rename unit tests for legacy API. * Implement basic Hetzner API support for storagebox_info. * Add changelog fragment. * Implement basic Hetzner API support for storagebox. * Adjust imports. * Do not count not used code path for coverage. * Update docs.
1 parent 39571ff commit dafdd16

24 files changed

+2282
-127
lines changed

antsibull-nox.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ pattern = "^.*$"
4545
exclusions = []
4646
doc_fragment = "community.hrobot.attributes.actiongroup_robot"
4747

48+
[[sessions.extra_checks.action_groups_config]]
49+
name = "api"
50+
pattern = "^storagebox.*$"
51+
exclusions = [
52+
"storagebox_set_password",
53+
"storagebox_snapshot_info",
54+
"storagebox_snapshot_plan_info",
55+
"storagebox_snapshot_plan",
56+
"storagebox_snapshot",
57+
"storagebox_subaccount_info",
58+
"storagebox_subaccount",
59+
]
60+
doc_fragment = "community.hrobot.attributes.actiongroup_api"
61+
4862
[sessions.build_import_check]
4963
run_galaxy_importer = true
5064

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
known_issues:
2+
- "storagebox* modules - the Hetzner Robot API for storage boxes is
3+
`deprecated and will be sunset on July 30, 2025 <https://docs.hetzner.cloud/changelog#2025-06-25-new-api-for-storage-boxes>`__.
4+
The modules are currently not compatible with the new API. We will try to adjust them until then,
5+
but usage and return values might change slightly due to differences in the APIs.
6+
7+
For the new API, an API token needs to be registered and provided as ``hetzner_token``
8+
(https://github.com/ansible-collections/community.hrobot/pull/166)."
9+
minor_changes:
10+
- "storagebox - support the new Hetzner API (https://github.com/ansible-collections/community.hrobot/pull/166)."
11+
- "storagebox_info - support the new Hetzner API (https://github.com/ansible-collections/community.hrobot/pull/166)."

meta/runtime.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ action_groups:
2828
- storagebox_subaccount
2929
- storagebox_subaccount_info
3030
- v_switch
31+
api:
32+
- storagebox
33+
- storagebox_info

plugins/doc_fragments/api.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -*- coding: utf-8 -*-
2+
3+
# Copyright (c) 2025 Felix Fontein <[email protected]>
4+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
5+
# SPDX-License-Identifier: GPL-3.0-or-later
6+
7+
from __future__ import (absolute_import, division, print_function)
8+
__metaclass__ = type
9+
10+
11+
class ModuleDocFragment(object):
12+
13+
# Standard files documentation fragment
14+
DOCUMENTATION = r"""
15+
options:
16+
hetzner_token:
17+
description:
18+
- The API token for the Robot web-service user.
19+
type: str
20+
required: true
21+
rate_limit_retry_timeout:
22+
description:
23+
- Timeout (in seconds) for waiting when rate limit exceeded errors are returned.
24+
- Set to V(0) to not retry.
25+
- Set to a negative value like V(-1) to retry forever.
26+
type: int
27+
default: -1
28+
"""
29+
30+
# Only for transition period
31+
_ROBOT_COMPAT_SHIM = r"""
32+
options:
33+
hetzner_token:
34+
description:
35+
- The API token for the Robot web-service user.
36+
- One of O(hetzner_token) and O(hetzner_user) must be specified.
37+
required: false
38+
hetzner_user:
39+
description:
40+
- The username for the Robot web-service user.
41+
- One of O(hetzner_token) and O(hetzner_user) must be specified.
42+
- If O(hetzner_user) is specified, O(hetzner_password) must also be specified, and O(hetzner_token) must not be specified.
43+
required: false
44+
hetzner_password:
45+
description:
46+
- The password for the Robot web-service user.
47+
- If O(hetzner_password) is specified, O(hetzner_user) must also be specified, and O(hetzner_token) must not be specified.
48+
required: false
49+
"""

plugins/doc_fragments/attributes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ class ModuleDocFragment(object):
5858
- community.hrobot.robot
5959
'''
6060

61+
ACTIONGROUP_API = r'''
62+
options: {}
63+
attributes:
64+
action_group:
65+
description: Use C(group/community.hrobot.api) in C(module_defaults) to set defaults for this module.
66+
support: full
67+
membership:
68+
- community.hrobot.api
69+
'''
70+
71+
# Only for transition period
72+
_ACTIONGROUP_ROBOT_AND_API = r'''
73+
options: {}
74+
attributes:
75+
action_group:
76+
description: Use C(group/community.hrobot.robot) or C(group/community.hrobot.api) in C(module_defaults) to set defaults for this module.
77+
support: full
78+
membership:
79+
- community.hrobot.api
80+
- community.hrobot.robot
81+
'''
82+
6183
CONN = r"""
6284
options: {}
6385
attributes:

plugins/doc_fragments/robot.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ class ModuleDocFragment(object):
1414
DOCUMENTATION = r"""
1515
options:
1616
hetzner_user:
17-
description: The username for the Robot web-service user.
17+
description:
18+
- The username for the Robot web-service user.
1819
type: str
1920
required: true
2021
hetzner_password:
21-
description: The password for the Robot web-service user.
22+
description:
23+
- The password for the Robot web-service user.
2224
type: str
2325
required: true
2426
rate_limit_retry_timeout:

plugins/inventory/robot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@
9999

100100
from ansible_collections.community.library_inventory_filtering_v1.plugins.plugin_utils.inventory_filter import parse_filters, filter_host
101101

102+
from ansible_collections.community.hrobot.plugins.module_utils.common import (
103+
PluginException,
104+
)
102105
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
103106
BASE_URL,
104-
PluginException,
105107
plugin_open_url_json,
106108
)
107109
from ansible_collections.community.hrobot.plugins.plugin_utils.unsafe import make_unsafe

plugins/module_utils/api.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)