1
0
Fork 0
mirror of https://github.com/ansible-collections/hetzner.hcloud.git synced 2026-02-04 16:11:49 +00:00

feat: allow recreating ssh key when public key in the API does not match (#634)

##### SUMMARY

- Log a warning when the provided public key does not match one in the
API.
- When the public key does not match the one in the API, allow
recreating the SSH Key in the API using the ``force=true`` argument.


Closes: #578 

##### ISSUE TYPE
- Feature Pull Request

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below
-->
`ssh_key`

##### ADDITIONAL INFORMATION
In Hetzner API, we do not have any public_key change endpoint and only
updating names and labels are allowed.
For public_key The only way is removing and re-creating. `force` option
allows users to do re-creation if needed.

---------

Co-authored-by: jo <ljonas@riseup.net>
This commit is contained in:
Amirhossein Shaerpour 2025-06-03 19:13:54 +03:30 committed by GitHub
parent 1ec09d252d
commit 4fc2003f30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 121 additions and 2 deletions

View file

@ -0,0 +1,4 @@
minor_changes:
- ssh_key - Log a warning when the provided public key does not match one in the API.
- ssh_key - When the public key does not match the one in the API, allow recreating the
SSH Key in the API using the ``force=true`` argument.

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from base64 import b64decode
from hashlib import md5
def ssh_public_key_md5_fingerprint(value: str) -> str:
"""
Compute the md5 fingerprint of a SSH public key.
"""
parts = value.strip().split()
if len(parts) < 2:
raise ValueError("invalid ssh public key")
raw = b64decode(parts[1].encode("ascii"))
digest = md5(raw).hexdigest()
return ":".join(a + b for a, b in zip(digest[::2], digest[1::2]))

View file

@ -44,6 +44,11 @@ options:
- The Public Key to add. - The Public Key to add.
- Required if ssh_key does not exist. - Required if ssh_key does not exist.
type: str type: str
force:
description:
- Recreate the SSH Key if the public key does not match the one in the API.
type: bool
default: false
state: state:
description: description:
- State of the ssh_key. - State of the ssh_key.
@ -115,6 +120,7 @@ hcloud_ssh_key:
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ..module_utils.hcloud import AnsibleHCloud from ..module_utils.hcloud import AnsibleHCloud
from ..module_utils.ssh import ssh_public_key_md5_fingerprint
from ..module_utils.vendor.hcloud import HCloudException from ..module_utils.vendor.hcloud import HCloudException
from ..module_utils.vendor.hcloud.ssh_keys import BoundSSHKey from ..module_utils.vendor.hcloud.ssh_keys import BoundSSHKey
@ -175,6 +181,21 @@ class AnsibleHCloudSSHKey(AnsibleHCloud):
self.hcloud_ssh_key.update(labels=labels) self.hcloud_ssh_key.update(labels=labels)
self._mark_as_changed() self._mark_as_changed()
public_key = self.module.params.get("public_key")
if public_key is not None:
fingerprint = ssh_public_key_md5_fingerprint(public_key)
if fingerprint != self.hcloud_ssh_key.fingerprint:
if self.module.params.get("force"):
if not self.module.check_mode:
self.hcloud_ssh_key.delete()
self._create_ssh_key()
self._mark_as_changed()
else:
self.module.warn(
f"SSH Key '{self.hcloud_ssh_key.name}' in the API has a "
f"different public key than the one provided. "
f"Use the force=true argument to recreate the SSH Key in the API."
)
self._get_ssh_key() self._get_ssh_key()
def present_ssh_key(self): def present_ssh_key(self):
@ -204,6 +225,7 @@ class AnsibleHCloudSSHKey(AnsibleHCloud):
public_key={"type": "str"}, public_key={"type": "str"},
fingerprint={"type": "str"}, fingerprint={"type": "str"},
labels={"type": "dict"}, labels={"type": "dict"},
force={"type": "bool", "default": False},
state={ state={
"choices": ["absent", "present"], "choices": ["absent", "present"],
"default": "present", "default": "present",

View file

@ -17,3 +17,15 @@
path: "{{ _tmp_ssh_key_file.path }}" path: "{{ _tmp_ssh_key_file.path }}"
force: true force: true
register: test_ssh_keypair register: test_ssh_keypair
- name: Create temporary file for test_ssh_keypair2
ansible.builtin.tempfile:
path: ~/tmp
suffix: "{{ hcloud_ssh_key_name }}"
register: _tmp_ssh_key_file2
- name: Create test_ssh_keypair2
community.crypto.openssh_keypair:
path: "{{ _tmp_ssh_key_file2.path }}"
force: true
register: test_ssh_keypair2

View file

@ -93,7 +93,7 @@
key: value key: value
test: "val123" test: "val123"
register: result register: result
- name: test update ssh key with other labels - name: test update ssh key with other labels
assert: assert:
that: that:
- result is changed - result is changed
@ -136,9 +136,45 @@
- result is failed - result is failed
- result.failure.code == "uniqueness_error" - result.failure.code == "uniqueness_error"
- name: test update public key warning
hetzner.hcloud.ssh_key:
name: "{{ hcloud_ssh_key_name }}"
public_key: "{{ test_ssh_keypair2.public_key }}"
register: result
- name: verify update public key warning
assert:
that:
- result is not changed
- result.hcloud_ssh_key.name == hcloud_ssh_key_name
- result.hcloud_ssh_key.public_key == test_ssh_keypair.public_key
- name: test update public key with force
hetzner.hcloud.ssh_key:
name: "{{ hcloud_ssh_key_name }}"
public_key: "{{ test_ssh_keypair2.public_key }}"
force: true
register: result
- name: verify update public key with force
assert:
that:
- result is changed
- result.hcloud_ssh_key.name == hcloud_ssh_key_name
- result.hcloud_ssh_key.public_key == test_ssh_keypair2.public_key
- name: test update public key with force idempotence
hetzner.hcloud.ssh_key:
name: "{{ hcloud_ssh_key_name }}"
public_key: "{{ test_ssh_keypair2.public_key }}"
force: true
register: result
- name: verify update public key with force idempotence
assert:
that:
- result is not changed
- name: test delete ssh key - name: test delete ssh key
hetzner.hcloud.ssh_key: hetzner.hcloud.ssh_key:
id: "{{ ssh_key.hcloud_ssh_key.id }}" name: "{{ hcloud_ssh_key_name }}"
state: absent state: absent
register: result register: result
- name: verify absent ssh_key - name: verify absent ssh_key

View file

@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from ansible_collections.hetzner.hcloud.plugins.module_utils.ssh import (
ssh_public_key_md5_fingerprint,
)
@pytest.mark.parametrize(
("public_key", "fingerprint"),
[
(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILNWUEdTk1oxrjUZ5erbKUmJM3VxQ9DLocgt/HFohCf6 comment",
"ce:cf:37:b9:38:40:ad:80:b2:8b:2c:5c:83:b5:af:0b",
),
(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILNWUEdTk1oxrjUZ5erbKUmJM3VxQ9DLocgt/HFohCf6", # No comment
"ce:cf:37:b9:38:40:ad:80:b2:8b:2c:5c:83:b5:af:0b",
),
(
"ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBABOUmmgxbhhauMg97GMwHcjWXM35MwFmlSKx7klWpPr3jMbabGQzINFVXexgf6Tru0D5a7NU/Hkx9t2yOtqKHJOIQB5/NKktqYelul4X56WYV/64RSm6xIjcolNao9fUbawnIwh9mvaQQg5v1BiJfPJ6p6LcWPunzfm6DztU1tHwLtjTw== comment", # noqa: E501
"bf:61:7b:7f:ab:c7:af:25:aa:d7:d5:e8:5f:87:5c:66",
),
],
)
def test_ssh_public_key_md5_fingerprint(public_key: str, fingerprint: str):
assert ssh_public_key_md5_fingerprint(public_key) == fingerprint