Skip to content

Commit 68626a5

Browse files
authored
Implement basic Hetzner API support for more storagebox modules (#168)
* Implement basic Hetzner API support for storagebox_set_password. * Implement basic Hetzner API support for storagebox_snapshot_info. * Update/extend API. * Implement basic Hetzner API support for storagebox_snapshot.
1 parent 8ce7e7b commit 68626a5

File tree

12 files changed

+1192
-117
lines changed

12 files changed

+1192
-117
lines changed

antsibull-nox.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,6 @@ doc_fragment = "community.hrobot.attributes.actiongroup_robot"
4949
name = "api"
5050
pattern = "^storagebox.*$"
5151
exclusions = [
52-
"storagebox_set_password",
53-
"storagebox_snapshot_info",
54-
"storagebox_snapshot",
5552
"storagebox_subaccount_info",
5653
"storagebox_subaccount",
5754
]

changelogs/fragments/166-storagebox.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ minor_changes:
1515
and the new ``community.hrobot.api`` action group, and will eventually drop the ``community.hrobot.robot`` action group once
1616
the Robot API for storage boxes is removed by Hetzner
1717
(https://github.com/ansible-collections/community.hrobot/pull/166,
18-
https://github.com/ansible-collections/community.hrobot/pull/167).
18+
https://github.com/ansible-collections/community.hrobot/pull/167,
19+
https://github.com/ansible-collections/community.hrobot/pull/168).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
minor_changes:
2+
- "storagebox_set_password - support the new Hetzner API (https://github.com/ansible-collections/community.hrobot/pull/168)."
3+
- "storagebox_snapshot - support the new Hetzner API (https://github.com/ansible-collections/community.hrobot/pull/168)."
4+
- "storagebox_snapshot_info - support the new Hetzner API (https://github.com/ansible-collections/community.hrobot/pull/168)."

meta/runtime.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,8 @@ action_groups:
3131
api:
3232
- storagebox
3333
- storagebox_info
34+
- storagebox_set_password
35+
- storagebox_snapshot
36+
- storagebox_snapshot_info
3437
- storagebox_snapshot_plan
3538
- storagebox_snapshot_plan_info

plugins/module_utils/api.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def api_fetch_url_json_list(
189189
method='GET',
190190
timeout=10,
191191
headers=None,
192+
accept_errors=None,
192193
page_size=100,
193194
):
194195
'''
@@ -199,13 +200,17 @@ def api_fetch_url_json_list(
199200
result_list = []
200201
while page is not None and (last_page is None or last_page >= page):
201202
page_url = '{0}{1}{2}'.format(url, '&' if '?' in url else '?', deterministic_urlencode({"page": str(page), "per_page": page_size}))
202-
result, dummy, dummy2 = api_fetch_url_json(
203+
result, dummy, error = api_fetch_url_json(
203204
module,
204205
page_url,
205206
method=method,
206207
timeout=timeout,
207208
headers=headers,
209+
accept_errors=accept_errors,
208210
)
211+
# TODO: add coverage!
212+
if error: # pragma: no cover
213+
return result_list, error # pragma: no cover
209214
if isinstance(result.get(data_key), list):
210215
result_list += result[data_key]
211216
if isinstance(result.get("meta"), dict) and isinstance(result["meta"].get("pagination"), dict):
@@ -220,7 +225,7 @@ def api_fetch_url_json_list(
220225
break
221226
else:
222227
page += 1
223-
return result_list
228+
return result_list, None
224229

225230

226231
def api_fetch_url_json_with_retries(module, url, check_done_callback, check_done_delay=10, check_done_timeout=180, skip_first=False, **kwargs):
@@ -255,15 +260,27 @@ class ApplyActionError(Exception):
255260
pass
256261

257262

258-
def api_apply_action(module, action_url, action_data, action_check_url_provider, check_done_delay=10, check_done_timeout=180):
263+
def api_apply_action(
264+
module,
265+
action_url,
266+
action_data,
267+
action_check_url_provider,
268+
method='POST',
269+
check_done_delay=10,
270+
check_done_timeout=180,
271+
accept_errors=None,
272+
):
259273
headers = {"Content-type": "application/json"} if action_data is not None else {}
260-
result, dummy, dummy2 = api_fetch_url_json(
274+
result, dummy, error = api_fetch_url_json(
261275
module,
262276
action_url,
263277
data=module.jsonify(action_data) if action_data is not None else None,
264278
headers=headers,
265-
method='POST',
279+
method=method,
280+
accept_errors=accept_errors,
266281
)
282+
if error:
283+
return error
267284
action_id = result["action"]["id"]
268285
if result["action"]["status"] == "running":
269286
this_action_url = action_check_url_provider(action_id)
@@ -284,3 +301,4 @@ def action_done_callback(result_, info_, error_):
284301
raise ApplyActionError('[{0}] {1}'.format(to_native(error.get("code")), to_native(error.get("message"))))
285302
elif result["action"]["status"] == "error":
286303
raise ApplyActionError('Unknown error')
304+
return None

plugins/modules/storagebox_info.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ def main():
624624
storageboxes = [result["storage_box"]]
625625
else:
626626
url = "{0}/v1/storage_boxes".format(API_BASE_URL)
627-
storageboxes = api_fetch_url_json_list(module, url, data_key="storage_boxes")
627+
storageboxes, dummy = api_fetch_url_json_list(module, url, data_key="storage_boxes")
628628
storageboxes = [add_hrobot_compat_shim(storagebox) for storagebox in storageboxes]
629629

630630
module.exit_json(

plugins/modules/storagebox_set_password.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717
description:
1818
- (Re)set the password for a storage box.
1919
extends_documentation_fragment:
20+
- community.hrobot.api._robot_compat_shim # must come before api and robot
21+
- community.hrobot.api
2022
- community.hrobot.robot
2123
- community.hrobot.attributes
24+
- community.hrobot.attributes._actiongroup_robot_and_api # must come before the other two!
25+
- community.hrobot.attributes.actiongroup_api
2226
- community.hrobot.attributes.actiongroup_robot
2327
2428
attributes:
@@ -32,6 +36,8 @@
3236
- This module performs an action on every invocation.
3337
3438
options:
39+
hetzner_token:
40+
version_added: 2.5.0
3541
id:
3642
description:
3743
- The ID of the storage box to modify.
@@ -42,6 +48,7 @@
4248
- The new password for the storage box.
4349
- If not provided, a random password will be created by the Robot API
4450
and returned as RV(password).
51+
- This option is required if O(hetzner_token) is provided, since the new API does not support setting (and returning) a random password.
4552
type: str
4653
"""
4754

@@ -52,7 +59,7 @@
5259
id: 123
5360
password: "newpassword"
5461
55-
- name: Set a random password
62+
- name: Set a random password (only works with the legacy Robot API)
5663
community.hrobot.storagebox_set_password:
5764
id: 123
5865
register: result
@@ -79,41 +86,83 @@
7986
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
8087
BASE_URL,
8188
ROBOT_DEFAULT_ARGUMENT_SPEC,
89+
_ROBOT_DEFAULT_ARGUMENT_SPEC_COMPAT,
8290
fetch_url_json,
8391
)
8492

93+
from ansible_collections.community.hrobot.plugins.module_utils.api import (
94+
API_BASE_URL,
95+
API_DEFAULT_ARGUMENT_SPEC,
96+
_API_DEFAULT_ARGUMENT_SPEC_COMPAT,
97+
ApplyActionError,
98+
api_apply_action,
99+
)
100+
85101

86102
def main():
87-
argument_spect = dict(
103+
argument_spec = dict(
88104
id=dict(type="int", required=True),
89105
password=dict(type="str", no_log=True),
90106
)
91-
argument_spect.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
92-
module = AnsibleModule(argument_spect, supports_check_mode=False)
107+
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
108+
argument_spec.update(_ROBOT_DEFAULT_ARGUMENT_SPEC_COMPAT)
109+
argument_spec.update(API_DEFAULT_ARGUMENT_SPEC)
110+
argument_spec.update(_API_DEFAULT_ARGUMENT_SPEC_COMPAT)
111+
module = AnsibleModule(
112+
argument_spec,
113+
supports_check_mode=False,
114+
required_by={"hetzner_token": "password"},
115+
)
93116

94117
id = module.params["id"]
95118
password = module.params.get("password")
96119

97-
url = "{0}/storagebox/{1}/password".format(BASE_URL, id)
98-
accepted_errors = ["STORAGEBOX_NOT_FOUND", "STORAGEBOX_INVALID_PASSWORD"]
120+
if module.params["hetzner_user"] is not None:
121+
# DEPRECATED: old API
122+
url = "{0}/storagebox/{1}/password".format(BASE_URL, id)
123+
accepted_errors = ["STORAGEBOX_NOT_FOUND", "STORAGEBOX_INVALID_PASSWORD"]
99124

100-
if password:
101-
headers = {"Content-type": "application/x-www-form-urlencoded"}
102-
result, error = fetch_url_json(
103-
module, url, method="POST", accept_errors=accepted_errors, data=urlencode({"password": password}), headers=headers)
104-
else:
105-
result, error = fetch_url_json(
106-
module, url, method="POST", accept_errors=accepted_errors)
125+
if password:
126+
headers = {"Content-type": "application/x-www-form-urlencoded"}
127+
result, error = fetch_url_json(
128+
module, url, method="POST", accept_errors=accepted_errors, data=urlencode({"password": password}), headers=headers)
129+
else:
130+
result, error = fetch_url_json(
131+
module, url, method="POST", accept_errors=accepted_errors)
132+
133+
if error == 'STORAGEBOX_NOT_FOUND':
134+
module.fail_json(
135+
msg='Storage Box with ID {0} not found'.format(id))
107136

108-
if error == 'STORAGEBOX_NOT_FOUND':
109-
module.fail_json(
110-
msg='Storage Box with ID {0} not found'.format(id))
137+
if error == 'STORAGEBOX_INVALID_PASSWORD':
138+
module.fail_json(
139+
msg="The chosen password has been considered insecure or does not comply with Hetzner's password guideline")
111140

112-
if error == 'STORAGEBOX_INVALID_PASSWORD':
113-
module.fail_json(
114-
msg="The chosen password has been considered insecure or does not comply with Hetzner's password guideline")
141+
module.exit_json(changed=True, password=result["password"])
115142

116-
module.exit_json(changed=True, password=result["password"])
143+
else:
144+
# NEW API!
145+
action_url = "{0}/v1/storage_boxes/{1}/actions/reset_password".format(API_BASE_URL, id)
146+
action = {
147+
"password": password,
148+
}
149+
try:
150+
error = api_apply_action(
151+
module,
152+
action_url,
153+
action,
154+
lambda action_id: "{0}/v1/storage_boxes/actions/{1}".format(API_BASE_URL, action_id),
155+
check_done_delay=1,
156+
check_done_timeout=60,
157+
accept_errors=["not_found"],
158+
)
159+
except ApplyActionError as exc:
160+
module.fail_json(msg='Error while resetting password: {0}'.format(exc))
161+
162+
if error == "not_found":
163+
module.fail_json(msg='Storage Box with ID {0} not found'.format(id))
164+
165+
module.exit_json(changed=True, password=password)
117166

118167

119168
if __name__ == '__main__': # pragma: no cover

0 commit comments

Comments
 (0)