Init: mediaserver

This commit is contained in:
2023-02-08 12:13:28 +01:00
parent 848bc9739c
commit f7c23d4ba9
31914 changed files with 6175775 additions and 0 deletions

View File

@@ -0,0 +1,444 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: boot
short_description: Set boot configuration
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Set the boot configuration for a dedicated server.
seealso:
- module: community.hrobot.ssh_key
description: Add, remove or update SSH key
- module: community.hrobot.ssh_key_info
description: Query information on SSH keys
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: none
options:
server_number:
description:
- The server number of the server whose boot configuration to adjust.
type: int
required: true
regular_boot:
description:
- If this option is provided, all special boot configurations are removed and
the installed operating system will be booted up next (assuming it is bootable).
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: bool
choices:
- true
rescue:
description:
- If this option is provided, the rescue system will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
os:
description:
- The operating system to use for the rescue system. Possible choices can
change over time.
- Currently, C(linux), C(linuxold), C(freebsd), C(freebsdold), C(freebsdax),
C(freebsdbetaax), C(vkvm), and C(vkvmold) seem to be available.
type: str
required: true
arch:
description:
- The architecture to use for the rescue system.
- Not all architectures are available for all operating systems.
- Defaults to C(64).
type: int
choices:
- 32
- 64
authorized_keys:
description:
- One or more SSH key fingerprints to equip the rescue system with.
- Only fingerprints for SSH keys deposited in the Robot API can be used.
- You can use the M(community.hrobot.ssh_key_info) module to query the
SSH keys you can use, and the M(community.hrobot.ssh_key) module to
add or update SSH keys.
type: list
elements: str
install_linux:
description:
- If this option is provided, a Linux system install will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
dist:
description:
- The distribution to install.
type: str
required: true
arch:
description:
- The architecture to use for the install.
- Not all architectures are available for all distributions.
- Defaults to C(64).
type: int
choices:
- 32
- 64
lang:
description:
- The language to use for the operating system.
type: str
required: true
authorized_keys:
description:
- One or more SSH key fingerprints to equip the rescue system with.
- Only fingerprints for SSH keys deposited in the Robot API can be used.
- You can use the M(community.hrobot.ssh_key_info) module to query the
SSH keys you can use, and the M(community.hrobot.ssh_key) module to
add or update SSH keys.
type: list
elements: str
install_vnc:
description:
- If this option is provided, a VNC installation will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
dist:
description:
- The distribution to install.
type: str
required: true
arch:
description:
- The architecture to use for the install.
- Not all architectures are available for all distributions.
- Defaults to C(64).
type: int
choices:
- 32
- 64
lang:
description:
- The language to use for the operating system.
type: str
required: true
install_windows:
description:
- If this option is provided, a Windows installation will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
lang:
description:
- The language to use for Windows.
type: str
required: true
install_plesk:
description:
- If this option is provided, a Plesk installation will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
dist:
description:
- The distribution to install.
type: str
required: true
arch:
description:
- The architecture to use for the install.
- Not all architectures are available for all distributions.
- Defaults to C(64).
type: int
choices:
- 32
- 64
lang:
description:
- The language to use for the operating system.
type: str
required: true
hostname:
description:
- The hostname.
type: str
required: true
install_cpanel:
description:
- If this option is provided, a cPanel installation will be activated for the next boot.
- Precisely one of I(regular_boot), I(rescue), I(install_linux), I(install_vnc),
I(install_windows), I(install_plesk), and I(install_cpanel) must be provided.
type: dict
suboptions:
dist:
description:
- The distribution to install.
type: str
required: true
arch:
description:
- The architecture to use for the install.
- Not all architectures are available for all distributions.
- Defaults to C(64).
type: int
choices:
- 32
- 64
lang:
description:
- The language to use for the operating system.
type: str
required: true
hostname:
description:
- The hostname.
type: str
required: true
'''
EXAMPLES = r'''
- name: Disable all special boot configurations
community.hrobot.boot:
hetzner_user: foo
hetzner_password: bar
regular_boot: true
- name: Enable a rescue system (64bit Linux) for the next boot
community.hrobot.boot:
hetzner_user: foo
hetzner_password: bar
rescue:
os: linux
- name: Enable a Linux install for the next boot
community.hrobot.boot:
hetzner_user: foo
hetzner_password: bar
install_linux:
dist: CentOS 5.5 minimal
lang: en
authorized_keys:
- 56:29:99:a4:5d:ed:ac:95:c1:f5:88:82:90:5d:dd:10
- 15:28:b0:03:95:f0:77:b3:10:56:15:6b:77:22:a5:bb
'''
RETURN = r'''
configuration_type:
description:
- Describes the active boot configuration.
returned: success
type: str
choices:
- regular_boot
- rescue
- install_linux
- install_vnc
- install_windows
- install_plesk
- install_cpanel
password:
description:
- The root password for the active boot configuration, if available.
- For non-rescue boot configurations, it is avised to change the root password
as soon as possible.
returned: success and if a boot configuration other than C(regular_boot) is active
type: str
'''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
BOOT_CONFIGURATION_DATA = [
('rescue', 'rescue', {
'os': ('os', 'os'),
'arch': ('arch', 'arch'),
'authorized_keys': ('authorized_key', 'authorized_key'),
}),
('install_linux', 'linux', {
'dist': ('dist', 'dist'),
'arch': ('arch', 'arch'),
'lang': ('lang', 'lang'),
'authorized_keys': ('authorized_key', 'authorized_key'),
}),
('install_vnc', 'vnc', {
'dist': ('dist', 'dist'),
'arch': ('arch', 'arch'),
'lang': ('lang', 'lang'),
}),
('install_windows', 'windows', {
'lang': ('lang', 'lang'),
}),
('install_plesk', 'plesk', {
'dist': ('dist', 'dist'),
'arch': ('arch', 'arch'),
'lang': ('lang', 'lang'),
'hostname': ('hostname', 'hostname'),
}),
('install_cpanel', 'cpanel', {
'dist': ('dist', 'dist'),
'arch': ('arch', 'arch'),
'lang': ('lang', 'lang'),
'hostname': ('hostname', 'hostname'),
}),
]
def main():
argument_spec = dict(
server_number=dict(type='int', required=True),
regular_boot=dict(type='bool', choices=[True]),
rescue=dict(type='dict', options=dict(
os=dict(type='str', required=True),
arch=dict(type='int', choices=[32, 64]),
authorized_keys=dict(type='list', elements='str', no_log=False),
)),
install_linux=dict(type='dict', options=dict(
dist=dict(type='str', required=True),
arch=dict(type='int', choices=[32, 64]),
lang=dict(type='str', required=True),
authorized_keys=dict(type='list', elements='str', no_log=False),
)),
install_vnc=dict(type='dict', options=dict(
dist=dict(type='str', required=True),
arch=dict(type='int', choices=[32, 64]),
lang=dict(type='str', required=True),
)),
install_windows=dict(type='dict', options=dict(
lang=dict(type='str', required=True),
)),
install_plesk=dict(type='dict', options=dict(
dist=dict(type='str', required=True),
arch=dict(type='int', choices=[32, 64]),
lang=dict(type='str', required=True),
hostname=dict(type='str', required=True),
)),
install_cpanel=dict(type='dict', options=dict(
dist=dict(type='str', required=True),
arch=dict(type='int', choices=[32, 64]),
lang=dict(type='str', required=True),
hostname=dict(type='str', required=True),
)),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
mutually_exclusive=[('regular_boot', 'rescue', 'install_linux', 'install_vnc', 'install_windows', 'install_plesk', 'install_cpanel')],
required_one_of=[('regular_boot', 'rescue', 'install_linux', 'install_vnc', 'install_windows', 'install_plesk', 'install_cpanel')],
)
server_number = module.params['server_number']
changed = False
# Retrieve current boot config
url = "{0}/boot/{1}".format(BASE_URL, server_number)
result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND', 'BOOT_NOT_AVAILABLE'])
if error is not None:
if error == 'SERVER_NOT_FOUND':
module.fail_json(msg='This server does not exist, or you do not have access rights for it')
if error == 'BOOT_NOT_AVAILABLE':
module.fail_json(msg='There is no boot configuration available for this server')
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
# Deactivate current boot configurations that are not requested
for option_name, other_name, dummy in BOOT_CONFIGURATION_DATA:
if (result['boot'].get(other_name) or {}).get('active') and not module.params[option_name]:
changed = True
if not module.check_mode:
url = "{0}/boot/{1}/{2}".format(BASE_URL, server_number, other_name)
fetch_url_json(module, url, method='DELETE', allow_empty_result=True)
# Enable/compare boot configuration
return_values = {
'configuration_type': 'regular_boot',
'password': None,
}
for option_name, other_name, options in BOOT_CONFIGURATION_DATA:
if module.params[option_name]:
return_values['configuration_type'] = option_name
existing = result['boot'].get(other_name) or {}
return_values['password'] = existing.get('password')
data = {}
for option_key, (result_key, data_key) in options.items():
option = module.params[option_name][option_key]
if option is None or option == []:
continue
data[data_key] = option
if existing.get('active'):
# Idempotence check
needs_change = False
for option_key, (result_key, data_key) in options.items():
should = module.params[option_name][option_key]
if should is None:
continue
# unfold the return object for the idempotence check to work correctly
has = existing.get(data_key)
if has and option_key == 'authorized_keys':
has = [x['key']['fingerprint'] for x in has]
if isinstance(has, list):
has = sorted(has)
if not isinstance(should, list):
should = [should]
should = sorted(should)
if should != has:
needs_change = True
else:
needs_change = True
if needs_change:
changed = True
if not module.check_mode:
url = "{0}/boot/{1}/{2}".format(BASE_URL, server_number, other_name)
if existing.get('active'):
# Deactivate existing boot configuration
fetch_url_json(module, url, method='DELETE', allow_empty_result=True)
# Enable new boot configuration
headers = {"Content-type": "application/x-www-form-urlencoded"}
result, dummy = fetch_url_json(
module,
url,
data=urlencode(data, True),
headers=headers,
method='POST',
)
return_values['password'] = (result.get(other_name) or {}).get('password')
else:
return_values['password'] = None
module.exit_json(changed=changed, **return_values)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,154 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: failover_ip
short_description: Manage Hetzner's failover IPs
author:
- Felix Fontein (@felixfontein)
description:
- Manage Hetzner's failover IPs.
seealso:
- name: Failover IP documentation
description: Hetzner's documentation on failover IPs.
link: https://docs.hetzner.com/robot/dedicated-server/ip/failover/
- module: community.hrobot.failover_ip_info
description: Retrieve information on failover IPs.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: full
options:
failover_ip:
description: The failover IP address.
type: str
required: true
state:
description:
- Defines whether the IP will be routed or not.
- If set to C(routed), I(value) must be specified.
type: str
choices:
- routed
- unrouted
default: routed
value:
description:
- The new value for the failover IP address.
- Required when setting I(state) to C(routed).
type: str
timeout:
description:
- Timeout to use when routing or unrouting the failover IP.
- Note that the API call returns when the failover IP has been
successfully routed to the new address, respectively successfully
unrouted.
type: int
default: 180
'''
EXAMPLES = r'''
- name: Set value of failover IP 1.2.3.4 to 5.6.7.8
community.hrobot.failover_ip:
hetzner_user: foo
hetzner_password: bar
failover_ip: 1.2.3.4
value: 5.6.7.8
- name: Set value of failover IP 1.2.3.4 to unrouted
community.hrobot.failover_ip:
hetzner_user: foo
hetzner_password: bar
failover_ip: 1.2.3.4
state: unrouted
'''
RETURN = r'''
value:
description:
- The value of the failover IP.
- Will be C(none) if the IP is unrouted.
returned: success
type: str
state:
description:
- Will be C(routed) or C(unrouted).
returned: success
type: str
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
ROBOT_DEFAULT_ARGUMENT_SPEC,
)
from ansible_collections.community.hrobot.plugins.module_utils.failover import (
get_failover,
set_failover,
get_failover_state,
)
def main():
argument_spec = dict(
failover_ip=dict(type='str', required=True),
state=dict(type='str', default='routed', choices=['routed', 'unrouted']),
value=dict(type='str'),
timeout=dict(type='int', default=180),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=(
('state', 'routed', ['value']),
),
)
failover_ip = module.params['failover_ip']
value = get_failover(module, failover_ip)
changed = False
before = get_failover_state(value)
if module.params['state'] == 'routed':
new_value = module.params['value']
else:
new_value = None
if value != new_value:
if module.check_mode:
value = new_value
changed = True
else:
value, changed = set_failover(module, failover_ip, new_value, timeout=module.params['timeout'])
after = get_failover_state(value)
module.exit_json(
changed=changed,
diff=dict(
before=before,
after=after,
),
**after
)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,127 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: failover_ip_info
short_description: Retrieve information on Hetzner's failover IPs
author:
- Felix Fontein (@felixfontein)
description:
- Retrieve information on Hetzner's failover IPs.
seealso:
- name: Failover IP documentation
description: Hetzner's documentation on failover IPs.
link: https://docs.hetzner.com/robot/dedicated-server/ip/failover/
- module: community.hrobot.failover_ip
description: Manage failover IPs.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
- community.hrobot.attributes.info_module
attributes:
action_group:
version_added: 1.6.0
options:
failover_ip:
description: The failover IP address.
type: str
required: true
'''
EXAMPLES = r'''
- name: Get value of failover IP 1.2.3.4
community.hrobot.failover_ip_info:
hetzner_user: foo
hetzner_password: bar
failover_ip: 1.2.3.4
value: 5.6.7.8
register: result
- name: Print value of failover IP 1.2.3.4 in case it is routed
ansible.builtin.debug:
msg: "1.2.3.4 routes to {{ result.value }}"
when: result.state == 'routed'
'''
RETURN = r'''
value:
description:
- The value of the failover IP.
- Will be C(none) if the IP is unrouted.
returned: success
type: str
state:
description:
- Will be C(routed) or C(unrouted).
returned: success
type: str
failover_ip:
description:
- The failover IP.
returned: success
type: str
sample: '1.2.3.4'
failover_netmask:
description:
- The netmask for the failover IP.
returned: success
type: str
sample: '255.255.255.255'
server_ip:
description:
- The main IP of the server this failover IP is associated to.
- This is I(not) the server the failover IP is routed to.
returned: success
type: str
server_number:
description:
- The number of the server this failover IP is associated to.
- This is I(not) the server the failover IP is routed to.
returned: success
type: int
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
ROBOT_DEFAULT_ARGUMENT_SPEC,
)
from ansible_collections.community.hrobot.plugins.module_utils.failover import (
get_failover_record,
get_failover_state,
)
def main():
argument_spec = dict(
failover_ip=dict(type='str', required=True),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
failover = get_failover_record(module, module.params['failover_ip'])
result = get_failover_state(failover['active_server_ip'])
result['failover_ip'] = failover['ip']
result['failover_netmask'] = failover['netmask']
result['server_ip'] = failover['server_ip']
result['server_number'] = failover['server_number']
result['changed'] = False
module.exit_json(**result)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,558 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: firewall
short_description: Manage Hetzner's dedicated server firewall
author:
- Felix Fontein (@felixfontein)
description:
- Manage Hetzner's dedicated server firewall.
- Note that idempotency check for TCP flags simply compares strings and doesn't
try to interpret the rules. This might change in the future.
requirements:
- ipaddress
seealso:
- name: Firewall documentation
description: Hetzner's documentation on the stateless firewall for dedicated servers
link: https://docs.hetzner.com/robot/dedicated-server/firewall/
- module: community.hrobot.firewall_info
description: Retrieve information on firewall configuration.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: full
options:
server_ip:
description: The server's main IP address.
required: true
type: str
port:
description:
- Switch port of firewall.
type: str
choices: [ main, kvm ]
default: main
state:
description:
- Status of the firewall.
- Firewall is active if state is C(present), and disabled if state is C(absent).
type: str
default: present
choices: [ present, absent ]
allowlist_hos:
description:
- Whether Hetzner services have access.
type: bool
aliases:
- whitelist_hos
rules:
description:
- Firewall rules.
type: dict
suboptions:
input:
description:
- Input firewall rules.
type: list
elements: dict
suboptions:
name:
description:
- Name of the firewall rule.
type: str
ip_version:
description:
- Internet protocol version.
- Note that currently, only IPv4 is supported by Hetzner.
required: true
type: str
choices: [ ipv4, ipv6 ]
dst_ip:
description:
- Destination IP address or subnet address.
- CIDR notation.
type: str
dst_port:
description:
- Destination port or port range.
type: str
src_ip:
description:
- Source IP address or subnet address.
- CIDR notation.
type: str
src_port:
description:
- Source port or port range.
type: str
protocol:
description:
- Protocol above IP layer
type: str
tcp_flags:
description:
- TCP flags or logical combination of flags.
- Flags supported by Hetzner are C(syn), C(fin), C(rst), C(psh) and C(urg).
- They can be combined with C(|) (logical or) and C(&) (logical and).
- See L(the documentation,https://wiki.hetzner.de/index.php/Robot_Firewall/en#Parameter)
for more information.
type: str
action:
description:
- Action if rule matches.
required: true
type: str
choices: [ accept, discard ]
update_timeout:
description:
- Timeout to use when configuring the firewall.
- Note that the API call returns before the firewall has been
successfully set up.
type: int
default: 30
wait_for_configured:
description:
- Whether to wait until the firewall has been successfully configured before
determining what to do, and before returning from the module.
- The API returns status C(in progress) when the firewall is currently
being configured. If this happens, the module will try again until
the status changes to C(active) or C(disabled).
- Please note that there is a request limit. If you have to do multiple
updates, it can be better to disable waiting, and regularly use
M(community.hrobot.firewall_info) to query status.
type: bool
default: true
wait_delay:
description:
- Delay to wait (in seconds) before checking again whether the firewall has
been configured.
type: int
default: 10
timeout:
description:
- Timeout (in seconds) for waiting for firewall to be configured.
type: int
default: 180
'''
EXAMPLES = r'''
- name: Configure firewall for server with main IP 1.2.3.4
community.hrobot.firewall:
hetzner_user: foo
hetzner_password: bar
server_ip: 1.2.3.4
state: present
allowlist_hos: true
rules:
input:
- name: Allow ICMP protocol, so you can ping your server
ip_version: ipv4
protocol: icmp
action: accept
- name: Allow responses to incoming TCP connections
ip_version: ipv4
protocol: tcp
dst_port: '32768-65535'
tcp_flags: ack
action: accept
- name: Allow everything to ports 20-23 from 4.3.2.1/24
ip_version: ipv4
src_ip: 4.3.2.1/24
dst_port: '20-23'
action: accept
- name: Allow everything to port 443
ip_version: ipv4
dst_port: '443'
action: accept
- name: Drop everything else
ip_version: ipv4
action: discard
register: result
- ansible.builtin.debug:
msg: "{{ result }}"
'''
RETURN = r'''
firewall:
description:
- The firewall configuration.
type: dict
returned: success
contains:
port:
description:
- Switch port of firewall.
- C(main) or C(kvm).
type: str
sample: main
server_ip:
description:
- Server's main IP address.
type: str
sample: 1.2.3.4
server_number:
description:
- Hetzner's internal server number.
type: int
sample: 12345
status:
description:
- Status of the firewall.
- C(active) or C(disabled).
- Will be C(in process) if the firewall is currently updated, and
I(wait_for_configured) is set to C(false) or I(timeout) to a too small value.
type: str
sample: active
allowlist_hos:
description:
- Whether Hetzner services have access.
type: bool
sample: true
version_added: 1.2.0
whitelist_hos:
description:
- Whether Hetzner services have access.
- Old name of return value C(allowlist_hos), will be removed eventually.
type: bool
sample: true
rules:
description:
- Firewall rules.
type: dict
contains:
input:
description:
- Input firewall rules.
type: list
elements: dict
contains:
name:
description:
- Name of the firewall rule.
type: str
sample: Allow HTTP access to server
ip_version:
description:
- Internet protocol version.
type: str
sample: ipv4
dst_ip:
description:
- Destination IP address or subnet address.
- CIDR notation.
type: str
sample: 1.2.3.4/32
dst_port:
description:
- Destination port or port range.
type: str
sample: "443"
src_ip:
description:
- Source IP address or subnet address.
- CIDR notation.
type: str
sample: null
src_port:
description:
- Source port or port range.
type: str
sample: null
protocol:
description:
- Protocol above IP layer
type: str
sample: tcp
tcp_flags:
description:
- TCP flags or logical combination of flags.
type: str
sample: null
action:
description:
- Action if rule matches.
- C(accept) or C(discard).
type: str
sample: accept
'''
import traceback
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
ROBOT_DEFAULT_ARGUMENT_SPEC,
BASE_URL,
fetch_url_json,
fetch_url_json_with_retries,
CheckDoneTimeoutException,
)
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible.module_utils.common.text.converters import to_native, to_text
try:
import ipaddress
HAS_IPADDRESS = True
IPADDRESS_IMP_ERR = None
except ImportError as exc:
IPADDRESS_IMP_ERR = traceback.format_exc()
HAS_IPADDRESS = False
RULE_OPTION_NAMES = [
'name', 'ip_version', 'dst_ip', 'dst_port', 'src_ip', 'src_port',
'protocol', 'tcp_flags', 'action',
]
RULES = ['input']
def restrict_dict(dictionary, fields):
result = dict()
for k, v in dictionary.items():
if k in fields:
result[k] = v
return result
def restrict_firewall_config(config):
result = restrict_dict(config, ['port', 'status', 'whitelist_hos'])
result['rules'] = dict()
for ruleset in RULES:
result['rules'][ruleset] = [
restrict_dict(rule, RULE_OPTION_NAMES)
for rule in config['rules'].get(ruleset) or []
]
return result
def update(before, after, params, name, param_name=None):
bv = before.get(name)
after[name] = bv
changed = False
pv = params[param_name or name]
if pv is not None:
changed = pv != bv
if changed:
after[name] = pv
return changed
def normalize_ip(ip, ip_version):
if ip is None:
return ip
if '/' in ip:
ip, range = ip.split('/')
else:
ip, range = ip, '' # pylint: disable=self-assigning-variable
ip_addr = to_native(ipaddress.ip_address(to_text(ip)).compressed)
if range == '':
range = '32' if ip_version.lower() == 'ipv4' else '128'
return ip_addr + '/' + range
def update_rules(before, after, params, ruleset):
before_rules = before['rules'][ruleset]
after_rules = after['rules'][ruleset]
params_rules = params['rules'][ruleset]
changed = len(before_rules) != len(params_rules)
for no, rule in enumerate(params_rules):
rule['src_ip'] = normalize_ip(rule['src_ip'], rule['ip_version'])
rule['dst_ip'] = normalize_ip(rule['dst_ip'], rule['ip_version'])
if no < len(before_rules):
before_rule = before_rules[no]
before_rule['src_ip'] = normalize_ip(before_rule['src_ip'], before_rule['ip_version'])
before_rule['dst_ip'] = normalize_ip(before_rule['dst_ip'], before_rule['ip_version'])
if before_rule != rule:
changed = True
after_rules.append(rule)
return changed
def encode_rule(output, rulename, input):
for i, rule in enumerate(input['rules'][rulename]):
for k, v in rule.items():
if v is not None:
output['rules[{0}][{1}][{2}]'.format(rulename, i, k)] = v
def create_default_rules_object():
rules = dict()
for ruleset in RULES:
rules[ruleset] = []
return rules
def fix_naming(firewall_result):
firewall_result = firewall_result.copy()
firewall_result['allowlist_hos'] = firewall_result.get('whitelist_hos', False)
return firewall_result
def firewall_configured(result, error):
return result['firewall']['status'] != 'in process'
def main():
argument_spec = dict(
server_ip=dict(type='str', required=True),
port=dict(type='str', default='main', choices=['main', 'kvm']),
state=dict(type='str', default='present', choices=['present', 'absent']),
allowlist_hos=dict(type='bool', aliases=['whitelist_hos']),
rules=dict(type='dict', options=dict(
input=dict(type='list', elements='dict', options=dict(
name=dict(type='str'),
ip_version=dict(type='str', required=True, choices=['ipv4', 'ipv6']),
dst_ip=dict(type='str'),
dst_port=dict(type='str'),
src_ip=dict(type='str'),
src_port=dict(type='str'),
protocol=dict(type='str'),
tcp_flags=dict(type='str'),
action=dict(type='str', required=True, choices=['accept', 'discard']),
)),
)),
update_timeout=dict(type='int', default=30),
wait_for_configured=dict(type='bool', default=True),
wait_delay=dict(type='int', default=10),
timeout=dict(type='int', default=180),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
if not HAS_IPADDRESS:
module.fail_json(msg=missing_required_lib('ipaddress'), exception=IPADDRESS_IMP_ERR)
# Sanitize input
module.params['status'] = 'active' if (module.params['state'] == 'present') else 'disabled'
if module.params['rules'] is None:
module.params['rules'] = {}
if module.params['rules'].get('input') is None:
module.params['rules']['input'] = []
server_ip = module.params['server_ip']
# https://robot.your-server.de/doc/webservice/en.html#get-firewall-server-ip
url = "{0}/firewall/{1}".format(BASE_URL, server_ip)
if module.params['wait_for_configured']:
try:
result, error = fetch_url_json_with_retries(
module,
url,
check_done_callback=firewall_configured,
check_done_delay=module.params['wait_delay'],
check_done_timeout=module.params['timeout'],
)
except CheckDoneTimeoutException as dummy:
module.fail_json(msg='Timeout while waiting for firewall to be configured.')
else:
result, error = fetch_url_json(module, url)
if not firewall_configured(result, error):
module.fail_json(msg='Firewall configuration cannot be read as it is not configured.')
full_before = result['firewall']
if not full_before.get('rules'):
full_before['rules'] = create_default_rules_object()
before = restrict_firewall_config(full_before)
# Build wanted (after) state and compare
after = dict(before)
changed = False
changed |= update(before, after, module.params, 'port')
changed |= update(before, after, module.params, 'status')
changed |= update(before, after, module.params, 'whitelist_hos', 'allowlist_hos')
after['rules'] = create_default_rules_object()
if module.params['status'] == 'active':
for ruleset in RULES:
changed |= update_rules(before, after, module.params, ruleset)
# Update if different
construct_result = True
construct_status = None
if changed and not module.check_mode:
# https://robot.your-server.de/doc/webservice/en.html#post-firewall-server-ip
url = "{0}/firewall/{1}".format(BASE_URL, server_ip)
headers = {"Content-type": "application/x-www-form-urlencoded"}
data = dict(after)
data['whitelist_hos'] = str(data['whitelist_hos']).lower()
del data['rules']
for ruleset in RULES:
encode_rule(data, ruleset, after)
result, error = fetch_url_json(
module,
url,
method='POST',
timeout=module.params['update_timeout'],
data=urlencode(data),
headers=headers,
)
if module.params['wait_for_configured'] and not firewall_configured(result, error):
try:
result, error = fetch_url_json_with_retries(
module,
url,
check_done_callback=firewall_configured,
check_done_delay=module.params['wait_delay'],
check_done_timeout=module.params['timeout'],
skip_first=True,
)
except CheckDoneTimeoutException as e:
result, error = e.result, e.error
module.warn('Timeout while waiting for firewall to be configured.')
full_after = result['firewall']
if not full_after.get('rules'):
full_after['rules'] = create_default_rules_object()
construct_status = full_after['status']
if construct_status != 'in process':
# Only use result if configuration is done, so that diff will be ok
after = restrict_firewall_config(full_after)
construct_result = False
if construct_result:
# Construct result (used for check mode, and configuration still in process)
full_after = dict(full_before)
for k, v in after.items():
if k != 'rules':
full_after[k] = after[k]
if construct_status is not None:
# We want 'in process' here
full_after['status'] = construct_status
full_after['rules'] = dict()
for ruleset in RULES:
full_after['rules'][ruleset] = after['rules'][ruleset]
module.exit_json(
changed=changed,
diff=dict(
before=fix_naming(before),
after=fix_naming(after),
),
firewall=fix_naming(full_after),
)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,241 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: firewall_info
short_description: Manage Hetzner's dedicated server firewall
author:
- Felix Fontein (@felixfontein)
description:
- Manage Hetzner's dedicated server firewall.
seealso:
- name: Firewall documentation
description: Hetzner's documentation on the stateless firewall for dedicated servers
link: https://docs.hetzner.com/robot/dedicated-server/firewall/
- module: community.hrobot.firewall
description: Configure firewall.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
- community.hrobot.attributes.info_module
attributes:
action_group:
version_added: 1.6.0
options:
server_ip:
description: The server's main IP address.
type: str
required: true
wait_for_configured:
description:
- Whether to wait until the firewall has been successfully configured before
returning from the module.
- The API returns status C(in progress) when the firewall is currently
being configured. If this happens, the module will try again until
the status changes to C(active) or C(disabled).
- Please note that there is a request limit. If you have to do multiple
updates, it can be better to disable waiting, and regularly use
M(community.hrobot.firewall_info) to query status.
type: bool
default: true
wait_delay:
description:
- Delay to wait (in seconds) before checking again whether the firewall has
been configured.
type: int
default: 10
timeout:
description:
- Timeout (in seconds) for waiting for firewall to be configured.
type: int
default: 180
'''
EXAMPLES = r'''
- name: Get firewall configuration for server with main IP 1.2.3.4
community.hrobot.firewall_info:
hetzner_user: foo
hetzner_password: bar
server_ip: 1.2.3.4
register: result
- ansible.builtin.debug:
msg: "{{ result.firewall }}"
'''
RETURN = r'''
firewall:
description:
- The firewall configuration.
type: dict
returned: success
contains:
port:
description:
- Switch port of firewall.
- C(main) or C(kvm).
type: str
sample: main
server_ip:
description:
- Server's main IP address.
type: str
sample: 1.2.3.4
server_number:
description:
- Hetzner's internal server number.
type: int
sample: 12345
status:
description:
- Status of the firewall.
- C(active) or C(disabled).
- Will be C(in process) if the firewall is currently updated, and
I(wait_for_configured) is set to C(false) or I(timeout) to a too small value.
type: str
sample: active
allowlist_hos:
description:
- Whether Hetzner services have access.
type: bool
sample: true
version_added: 1.2.0
whitelist_hos:
description:
- Whether Hetzner services have access.
- Old name of return value C(allowlist_hos), will be removed eventually.
type: bool
sample: true
rules:
description:
- Firewall rules.
type: dict
contains:
input:
description:
- Input firewall rules.
type: list
elements: dict
contains:
name:
description:
- Name of the firewall rule.
type: str
sample: Allow HTTP access to server
ip_version:
description:
- Internet protocol version.
type: str
sample: ipv4
dst_ip:
description:
- Destination IP address or subnet address.
- CIDR notation.
type: str
sample: 1.2.3.4/32
dst_port:
description:
- Destination port or port range.
type: str
sample: "443"
src_ip:
description:
- Source IP address or subnet address.
- CIDR notation.
type: str
sample: null
src_port:
description:
- Source port or port range.
type: str
sample: null
protocol:
description:
- Protocol above IP layer
type: str
sample: tcp
tcp_flags:
description:
- TCP flags or logical combination of flags.
type: str
sample: null
action:
description:
- Action if rule matches.
- C(accept) or C(discard).
type: str
sample: accept
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
ROBOT_DEFAULT_ARGUMENT_SPEC,
BASE_URL,
fetch_url_json,
fetch_url_json_with_retries,
CheckDoneTimeoutException,
)
def firewall_configured(result, error):
return result['firewall']['status'] != 'in process'
def main():
argument_spec = dict(
server_ip=dict(type='str', required=True),
wait_for_configured=dict(type='bool', default=True),
wait_delay=dict(type='int', default=10),
timeout=dict(type='int', default=180),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
server_ip = module.params['server_ip']
# https://robot.your-server.de/doc/webservice/en.html#get-firewall-server-ip
url = "{0}/firewall/{1}".format(BASE_URL, server_ip)
if module.params['wait_for_configured']:
try:
result, error = fetch_url_json_with_retries(
module,
url,
check_done_callback=firewall_configured,
check_done_delay=module.params['wait_delay'],
check_done_timeout=module.params['timeout'],
)
except CheckDoneTimeoutException as dummy:
module.fail_json(msg='Timeout while waiting for firewall to be configured.')
else:
result, error = fetch_url_json(module, url)
firewall = result['firewall']
firewall['allowlist_hos'] = firewall.get('whitelist_hos', False)
if not firewall.get('rules'):
firewall['rules'] = dict()
for ruleset in ['input']:
firewall['rules'][ruleset] = []
module.exit_json(
changed=False,
firewall=firewall,
)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,150 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: reset
short_description: Reset a dedicated server
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Reset a dedicated server with a software or hardware reset, or by requesting a manual reset.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: none
options:
server_number:
description:
- The server number of the server to reset.
type: int
required: true
reset_type:
description:
- How to reset the server.
- C(software) is a software reset. This should be similar to pressing Ctrl+Alt+Del on the keyboard.
- C(power) is a hardware reset similar to pressing the Power button. An ACPI signal is sent, and if the
server is configured correctly, this will trigger a regular shutdown.
- C(hardware) is a hardware reset similar to pressing the Restart button. The power is cycled for the server.
- C(manual) is a manual reset. This requests a technician to manually do the shutdown while looking at the
screen output. B(Be careful) and only use this when really necessary!
- Note that not every server supports every reset method!
type: str
required: true
choices:
- software
- hardware
- power
- manual
'''
EXAMPLES = r'''
- name: Send ACPI signal to server to request controlled shutdown
community.hrobot.reset:
hetzner_user: foo
hetzner_password: bar
failover_ip: 1.2.3.4
state: power
- name: Make sure that the server supports manual reset
community.hrobot.reset:
hetzner_user: foo
hetzner_password: bar
server_number: 1234
reset_type: manual
check_mode: true
- name: Request a manual reset (by a technican)
community.hrobot.reset:
hetzner_user: foo
hetzner_password: bar
server_number: 1234
reset_type: manual
'''
RETURN = r''' # '''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
def main():
argument_spec = dict(
server_number=dict(type='int', required=True),
reset_type=dict(type='str', required=True, choices=['software', 'hardware', 'power', 'manual']),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
server_number = module.params['server_number']
reset_type = {
'software': 'sw',
'hardware': 'hw',
'power': 'power',
'manual': 'man',
}[module.params['reset_type']]
if module.check_mode:
url = "{0}/reset/{1}".format(BASE_URL, server_number)
result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND', 'RESET_NOT_AVAILABLE'])
if not error and reset_type not in result['reset']['type']:
module.fail_json(msg='The chosen reset method is not supported for this server')
else:
headers = {"Content-type": "application/x-www-form-urlencoded"}
data = dict(
type=reset_type,
)
url = "{0}/reset/{1}".format(BASE_URL, server_number)
result, error = fetch_url_json(
module,
url,
data=urlencode(data),
headers=headers,
method='POST',
accept_errors=['INVALID_INPUT', 'SERVER_NOT_FOUND', 'RESET_NOT_AVAILABLE', 'RESET_MANUAL_ACTIVE', 'RESET_FAILED'],
)
if error and error == 'INVALID_INPUT':
module.fail_json(msg='The chosen reset method is not supported for this server')
if error:
if error == 'SERVER_NOT_FOUND':
module.fail_json(msg='This server does not exist, or you do not have access rights for it')
if error == 'RESET_NOT_AVAILABLE':
module.fail_json(msg='The server has no reset option available')
if error == 'RESET_MANUAL_ACTIVE':
module.fail_json(msg='A manual reset is already running')
if error == 'RESET_FAILED':
module.fail_json(msg='The reset failed due to an internal error at Hetzner')
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
module.exit_json(changed=True)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,133 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: reverse_dns
short_description: Set or remove reverse DNS entry for IP
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Allows to set, update or remove a reverse DNS entry for an IP address.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
notes:
- For the main IPv4 address of a server, deleting it actually sets it to a default hostname like
C(static.X.Y.Z.W.clients.your-server.de). This substitution (delete is replaced by changing to
this value) is done automatically by the API and results in the module not being idempotent
in this case.
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: none
options:
ip:
description:
- The IP address to set or remove a reverse DNS entry for.
type: str
required: true
state:
description:
- Whether to set or update (C(present)) or delete (C(absent)) the reverse DNS entry for I(ip).
type: str
default: present
choices:
- present
- absent
value:
description:
- The reverse DNS entry for I(ip).
- Required if I(state=present).
type: str
'''
EXAMPLES = r'''
- name: Set reverse DNS entry for 1.2.3.4
community.hrobot.reverse_dns:
hetzner_user: foo
hetzner_password: bar
ip: 1.2.3.4
value: foo.example.com
- name: Remove reverse DNS entry for 2a01:f48:111:4221::1
community.hrobot.reverse_dns:
hetzner_user: foo
hetzner_password: bar
ip: 2a01:f48:111:4221::1
state: absent
'''
RETURN = r''' # '''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
def main():
argument_spec = dict(
ip=dict(type='str', required=True),
state=dict(type='str', choices=['present', 'absent'], default='present'),
value=dict(type='str'),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[('state', 'present', ['value'])],
)
ip = module.params['ip']
state = module.params['state']
value = module.params['value']
url = "{0}/rdns/{1}".format(BASE_URL, ip)
result, error = fetch_url_json(module, url, accept_errors=['IP_NOT_FOUND', 'RDNS_NOT_FOUND'])
if error == 'RDNS_NOT_FOUND':
current = None
elif error:
if error == 'IP_NOT_FOUND':
module.fail_json(msg='The IP address was not found')
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
else:
current = result['rdns']['ptr']
changed = False
expected = value if state == 'present' else None
if current != expected:
changed = True
if not module.check_mode:
if expected is None:
fetch_url_json(module, url, method='DELETE', allow_empty_result=True)
else:
headers = {'Content-type': 'application/x-www-form-urlencoded'}
data = {'ptr': expected}
fetch_url_json(module, url, data=urlencode(data), headers=headers, method='POST')
module.exit_json(changed=changed)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,274 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: server
short_description: Update server information
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Allows to update server information.
- Right now the API only supports updating the server's name.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: none
options:
server_number:
description:
- The server number of the server to update.
type: int
required: true
server_name:
description:
- The server's name.
- If this option is not provided, it will not be adjusted.
type: str
'''
EXAMPLES = r'''
- name: Set server's name to foo.example.com
community.hrobot.server:
hetzner_user: foo
hetzner_password: bar
server_number: 123
server_name: foo.example.com
'''
RETURN = r'''
server:
description:
- Information on the server.
returned: success
type: dict
contains:
server_ip:
description:
- The server's main IP address.
type: str
sample: 123.123.123.123
returned: success
server_ipv6_net:
description:
- The server's main IPv6 network address.
type: str
sample: '2a01:f48:111:4221::'
returned: success
server_number:
description:
- The server's numeric ID.
type: int
sample: 321
returned: success
server_name:
description:
- The user-defined server's name.
type: str
sample: server1
returned: success
product:
description:
- The server product name.
type: str
sample: EQ 8
returned: success
dc:
description:
- The data center the server is located in.
type: str
sample: NBG1-DC1
returned: success
traffic:
description:
- Free traffic quota.
- C(unlimited) in case of unlimited traffic.
type: str
sample: 5 TB
returned: success
status:
description:
- Server status.
type: str
choices:
- ready
- in process
sample: ready
returned: success
cancelled:
description:
- Whether the server is cancelled.
type: bool
sample: false
returned: success
paid_until:
description:
- The date until the server has been paid.
type: str
sample: "2018-08-04"
returned: success
ip:
description:
- List of assigned single IP addresses.
type: list
elements: str
sample:
- 123.123.123.123
returned: success
subnet:
description:
- List of assigned subnets.
type: list
elements: dict
sample:
- ip: '2a01:4f8:111:4221::'
mask: 64
contains:
ip:
description:
- The first IP in the subnet.
type: str
sample: '2a01:4f8:111:4221::'
mask:
description:
- The masks bitlength.
type: str
sample: "64"
returned: success
reset:
description:
- Whether the server can be automatically reset.
type: bool
sample: true
returned: success
rescue:
description:
- Whether the rescue system is available.
type: bool
sample: false
returned: success
vnc:
description:
- Flag of VNC installation availability.
type: bool
sample: true
returned: success
windows:
description:
- Flag of Windows installation availability.
type: bool
sample: true
returned: success
plesk:
description:
- Flag of Plesk installation availability.
type: bool
sample: true
returned: success
cpanel:
description:
- Flag of cPanel installation availability.
type: bool
sample: true
returned: success
wol:
description:
- Flag of Wake On Lan availability.
type: bool
sample: true
returned: success
hot_swap:
description:
- Flag of Hot Swap availability.
type: bool
sample: true
returned: success
linked_storagebox:
description:
- Linked Storage Box ID.
type: int
sample: 12345
returned: success
'''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
def main():
argument_spec = dict(
server_number=dict(type='int', required=True),
server_name=dict(type='str'),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
server_number = module.params['server_number']
server_name = module.params['server_name']
url = "{0}/server/{1}".format(BASE_URL, server_number)
server, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND'])
if error:
module.fail_json(msg='This server does not exist, or you do not have access rights for it')
result = {
'changed': False,
'server': server['server'],
}
update = {}
if server_name is not None:
if server_name != result['server']['server_name']:
update['server_name'] = server_name
if update:
result['changed'] = True
if module.check_mode:
result['server'].update(update)
else:
headers = {"Content-type": "application/x-www-form-urlencoded"}
url = "{0}/server/{1}".format(BASE_URL, server_number)
server, error = fetch_url_json(
module,
url,
data=urlencode(update),
headers=headers,
method='POST',
accept_errors=['INVALID_INPUT'],
)
if error:
module.fail_json(msg='The values to update were invalid ({0})'.format(module.jsonify(update)))
result['server'] = server['server']
module.exit_json(**result)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,283 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: server_info
short_description: Query information on one or more servers
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Query information on one or more servers.
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
- community.hrobot.attributes.info_module
attributes:
action_group:
version_added: 1.6.0
options:
server_number:
description:
- Limit result list to server with this number.
type: int
server_name:
description:
- Limit result list to servers of this name.
type: str
full_info:
description:
- Whether to provide full information for every server.
- Setting this to C(true) requires one REST call per server,
which is slow and reduces your rate limit. Use with care.
- When I(server_number) is specified, this option is set to C(true).
type: bool
default: false
'''
EXAMPLES = r'''
- name: Query a list of all servers
community.hrobot.server_info:
hetzner_user: foo
hetzner_password: bar
register: result
- name: Query a specific server
community.hrobot.server_info:
hetzner_user: foo
hetzner_password: bar
server_number: 23
register: result
- name: Output data on specific server
ansible.builtin.debug:
msg: "Server name: {{ result.servers[0].server_name }}"
'''
RETURN = r'''
servers:
description:
- List of servers matching the provided options.
returned: success
type: list
elements: dict
contains:
server_ip:
description:
- The server's main IP address.
type: str
sample: 123.123.123.123
returned: success
server_ipv6_net:
description:
- The server's main IPv6 network address.
type: str
sample: '2a01:f48:111:4221::'
returned: success
server_number:
description:
- The server's numeric ID.
type: int
sample: 321
returned: success
server_name:
description:
- The user-defined server's name.
type: str
sample: server1
returned: success
product:
description:
- The server product name.
type: str
sample: EQ 8
returned: success
dc:
description:
- The data center the server is located in.
type: str
sample: NBG1-DC1
returned: success
traffic:
description:
- Free traffic quota.
- C(unlimited) in case of unlimited traffic.
type: str
sample: 5 TB
returned: success
status:
description:
- Server status.
type: str
choices:
- ready
- in process
sample: ready
returned: success
cancelled:
description:
- Whether the server is cancelled.
type: bool
sample: false
returned: success
paid_until:
description:
- The date until the server has been paid.
type: str
sample: "2018-08-04"
returned: success
ip:
description:
- List of assigned single IP addresses.
type: list
elements: str
sample:
- 123.123.123.123
returned: success
subnet:
description:
- List of assigned subnets.
type: list
elements: dict
sample:
- ip: '2a01:4f8:111:4221::'
mask: 64
contains:
ip:
description:
- The first IP in the subnet.
type: str
sample: '2a01:4f8:111:4221::'
mask:
description:
- The masks bitlength.
type: str
sample: "64"
returned: success
reset:
description:
- Whether the server can be automatically reset.
type: bool
sample: true
returned: when I(full_info=true)
rescue:
description:
- Whether the rescue system is available.
type: bool
sample: false
returned: when I(full_info=true)
vnc:
description:
- Flag of VNC installation availability.
type: bool
sample: true
returned: when I(full_info=true)
windows:
description:
- Flag of Windows installation availability.
type: bool
sample: true
returned: when I(full_info=true)
plesk:
description:
- Flag of Plesk installation availability.
type: bool
sample: true
returned: when I(full_info=true)
cpanel:
description:
- Flag of cPanel installation availability.
type: bool
sample: true
returned: when I(full_info=true)
wol:
description:
- Flag of Wake On Lan availability.
type: bool
sample: true
returned: when I(full_info=true)
hot_swap:
description:
- Flag of Hot Swap availability.
type: bool
sample: true
returned: when I(full_info=true)
linked_storagebox:
description:
- Linked Storage Box ID.
type: int
sample: 12345
returned: when I(full_info=true)
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
def main():
argument_spec = dict(
server_number=dict(type='int'),
server_name=dict(type='str'),
full_info=dict(type='bool', default=False),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
server_number = module.params['server_number']
server_name = module.params['server_name']
full_info = module.params['full_info']
servers = []
if server_number is not None:
server_numbers = [server_number]
else:
url = "{0}/server".format(BASE_URL)
result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND'])
server_numbers = []
if not error:
for entry in result:
if server_name is not None:
if entry['server']['server_name'] != server_name:
continue
if full_info:
server_numbers.append(entry['server']['server_number'])
else:
servers.append(entry['server'])
for server_number in server_numbers:
url = "{0}/server/{1}".format(BASE_URL, server_number)
result, error = fetch_url_json(module, url, accept_errors=['SERVER_NOT_FOUND'])
if not error:
if server_name is not None:
if result['server']['server_name'] != server_name:
continue
servers.append(result['server'])
module.exit_json(
changed=False,
servers=servers,
)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,245 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: ssh_key
short_description: Add, remove or update SSH key
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- Add, remove or update an SSH key stored in Hetzner's Robot.
seealso:
- module: community.hrobot.ssh_key_info
description: Query information on SSH keys
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
action_group:
version_added: 1.6.0
check_mode:
support: full
diff_mode:
support: none
options:
state:
description:
- Whether to make sure a public SSH key is present or absent.
- C(present) makes sure that the SSH key is available, and
potentially updates names for existing SHS public keys.
- C(absent) makes sure that the SSH key is not available.
The fingerprint or public key data is used for matching the
key.
required: true
type: str
choices:
- present
- absent
name:
description:
- The public key's name.
- Required if I(state=present), and ignored if I(state=absent).
type: str
fingerprint:
description:
- The MD5 fingerprint of the public SSH key to remove.
- One of I(public_key) and I(fingerprint) are required if I(state=absent).
type: str
public_key:
description:
- The public key data in OpenSSH format.
- "Example: C(ssh-rsa AAAAB3NzaC1yc+...)"
- One of I(public_key) and I(fingerprint) are required if I(state=absent).
- Required if I(state=present).
type: str
'''
EXAMPLES = r'''
- name: Add an SSH key
community.hrobot.ssh_key:
hetzner_user: foo
hetzner_password: bar
state: present
name: newKey
public_key: ssh-rsa AAAAB3NzaC1yc+...
- name: Remove a SSH key by fingerprint
community.hrobot.ssh_key:
hetzner_user: foo
hetzner_password: bar
state: absent
fingerprint: cb:8b:ef:a7:fe:04:87:3f:e5:55:cd:12:e3:e8:9f:99
'''
RETURN = r'''
fingerprint:
description:
- The MD5 fingerprint of the key.
- This is the value used to reference the SSH public key, for example in the M(community.hrobot.boot) module.
returned: success
type: str
sample: cb:8b:ef:a7:fe:04:87:3f:e5:55:cd:12:e3:e8:9f:99
'''
import base64
import binascii
import re
from ansible.module_utils.basic import AnsibleModule, AVAILABLE_HASH_ALGORITHMS
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
class FingerprintError(Exception):
pass
SPACE_RE = re.compile(' +')
FINGERPRINT_PART = re.compile('^[0-9a-f]{2}$')
def normalize_fingerprint(fingerprint, size=16):
if ':' in fingerprint:
fingerprint = fingerprint.split(':')
else:
fingerprint = [fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)]
if len(fingerprint) != size:
raise FingerprintError(
'Fingerprint must consist of {0} 8-bit hex numbers: got {1} 8-bit hex numbers instead'.format(size, len(fingerprint)))
for i, part in enumerate(fingerprint):
new_part = part.lower()
if len(new_part) < 2:
new_part = '0{0}'.format(new_part)
if not FINGERPRINT_PART.match(new_part):
raise FingerprintError(
'Fingerprint must consist of {0} 8-bit hex numbers: number {1} is invalid: "{2}"'.format(size, i + 1, part))
fingerprint[i] = new_part
return ':'.join(fingerprint)
def extract_fingerprint(public_key, alg='md5', size=16):
try:
public_key = SPACE_RE.split(public_key.strip())[1]
except IndexError:
raise FingerprintError(
'Error while extracting fingerprint from public key data: cannot split public key into at least two parts')
try:
public_key = base64.b64decode(public_key)
except (binascii.Error, TypeError) as exc:
raise FingerprintError(
'Error while extracting fingerprint from public key data: {0}'.format(exc))
try:
algorithm = AVAILABLE_HASH_ALGORITHMS[alg]
except KeyError:
raise FingerprintError(
'Hash algorithm {0} is not available. Possibly running in FIPS mode.'.format(alg.upper()))
digest = algorithm()
digest.update(public_key)
return normalize_fingerprint(digest.hexdigest(), size=size)
def main():
argument_spec = dict(
state=dict(type='str', required=True, choices=['present', 'absent']),
name=dict(type='str'),
fingerprint=dict(type='str'),
public_key=dict(type='str'),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
mutually_exclusive=[
('fingerprint', 'public_key'),
],
required_if=[
('state', 'present', ['name', 'public_key']),
('state', 'absent', ['fingerprint', 'public_key'], True),
],
)
state = module.params['state']
name = module.params['name']
fingerprint = module.params['fingerprint']
public_key = module.params['public_key']
try:
if fingerprint is not None:
fingerprint = normalize_fingerprint(fingerprint)
else:
fingerprint = extract_fingerprint(public_key)
except FingerprintError as exc:
module.fail_json(msg=to_native(exc))
url = "{0}/key/{1}".format(BASE_URL, fingerprint)
# Remove key
if state == 'absent':
if module.check_mode:
dummy, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND'])
else:
dummy, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND'], method='DELETE', allow_empty_result=True)
if error == 'NOT_FOUND':
changed = False
elif error is not None:
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
else:
changed = True
module.exit_json(changed=changed, fingerprint=fingerprint)
# Make sure key is present
result, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND'])
if error == 'NOT_FOUND':
changed = True
exists = False
elif error is not None:
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
else:
exists = True
changed = False
# The only thing we can update is the name
if result['key'].get('name') != name:
changed = True
if changed and not module.check_mode:
data = {
'name': name,
}
if not exists:
# Create key
data['data'] = ' '.join(SPACE_RE.split(public_key.strip())[:2])
url = "{0}/key".format(BASE_URL)
# Update or create key
headers = {"Content-type": "application/x-www-form-urlencoded"}
result, dummy = fetch_url_json(
module,
url,
data=urlencode(data),
headers=headers,
method='POST',
)
module.exit_json(changed=changed, fingerprint=fingerprint)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,113 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: ssh_key_info
short_description: Query information on SSH keys
version_added: 1.2.0
author:
- Felix Fontein (@felixfontein)
description:
- List information on all your SSH keys stored in Hetzner's Robot.
seealso:
- module: community.hrobot.ssh_key
description: Add, remove or update SSH key
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
- community.hrobot.attributes.info_module
attributes:
action_group:
version_added: 1.6.0
'''
EXAMPLES = r'''
- name: List all SSH keys
community.hrobot.ssh_key_info:
hetzner_user: foo
hetzner_password: bar
register: ssh_keys
- name: Show how many keys were found
ansible.builtin.debug:
msg: "Found {{ ssh_keys.ssh_keys | length }} keys"
'''
RETURN = r'''
ssh_keys:
description:
- The list of all SSH keys stored in Hetzner's Robot for your user.
returned: success
type: list
elements: dict
contains:
name:
description:
- The key's name shown in the UI.
type: str
sample: key1
fingerprint:
description:
- The key's MD5 fingerprint.
type: str
sample: 56:29:99:a4:5d:ed:ac:95:c1:f5:88:82:90:5d:dd:10
type:
description:
- The key's algorithm type.
type: str
sample: ECDSA
size:
description:
- The key's size in bits.
type: int
sample: 521
data:
description:
- The key data in OpenSSH's format.
type: str
sample: ecdsa-sha2-nistp521 AAAAE2VjZHNh ...
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
fetch_url_json,
)
def main():
argument_spec = dict()
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
url = "{0}/key".format(BASE_URL)
result, error = fetch_url_json(module, url, accept_errors=['NOT_FOUND'])
if error == 'NOT_FOUND':
result = []
elif error is not None:
raise AssertionError('Unexpected error {0}'.format(error)) # pragma: no cover
keys = []
for key in result:
keys.append(key['key'])
module.exit_json(changed=False, ssh_keys=keys)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover

View File

@@ -0,0 +1,504 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2022 Alexander Gil Casas <alexander.gilcasas@trustyou.net>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: v_switch
short_description: Manage Hetzner's vSwitch
version_added: 1.7.0
author:
- Alexander Gil Casas (@pando85)
description:
- Manage Hetzner's vSwitch.
seealso:
- name: vSwitch documentation
description: Hetzner's documentation on vSwitch for connecting dedicated servers.
link: https://docs.hetzner.com/robot/dedicated-server/network/vswitch
extends_documentation_fragment:
- community.hrobot.robot
- community.hrobot.attributes
- community.hrobot.attributes.actiongroup_robot
attributes:
check_mode:
support: full
diff_mode:
support: none
options:
vlan:
description:
- The vSwitch's VLAN ID.
- Range can be from 4000 to 4091.
- In order to identify a vSwitch both name and VLAN must match. If not, a new vSwitch will be created.
type: int
required: true
name:
description:
- The vSwitch's name.
- In order to identify a vSwitch both name and VLAN must match. If not, a new vSwitch will be created.
type: str
required: true
state:
description:
- State of the vSwitch.
- vSwitch is created if state is C(present), and deleted if state is C(absent).
- C(absent) just cancels the vSwitch at the end of the current day.
- When cancelling, you have to specify I(servers=[]) if you want to actively remove the servers in the vSwitch.
type: str
default: present
choices: [ present, absent ]
servers:
description:
- List of server identifiers (server's numeric ID or server's main IPv4 or IPv6).
- If servers is not specified, servers are not going to be deleted.
type: list
elements: str
wait:
description:
- Whether to wait until the vSwitch has been successfully configured before
determining what to do, and before returning from the module.
- The API returns status C(in process) when the vSwitch is currently
being set up in the servers. If this happens, the module will try again until
the status changes to C(ready) or server has been removed from vSwitch.
- Please note that if you disable wait while deleting and removing servers module
will fail with C(VSWITCH_IN_PROCESS) error.
type: bool
default: true
wait_delay:
description:
- Delay to wait (in seconds) before checking again whether the vSwitch servers has been configured.
type: int
default: 10
timeout:
description:
- Timeout (in seconds) for waiting for vSwitch servers to be configured.
type: int
default: 180
'''
EXAMPLES = r'''
- name: Create vSwitch with VLAN 4010 and name foo
community.hrobot.v_switch:
hetzner_user: foo
hetzner_password: bar
vlan: 4010
name: foo
- name: Create vSwitch with VLAN 4020 and name foo with two servers
community.hrobot.v_switch:
hetzner_user: foo
hetzner_password: bar
vlan: 4010
name: foo
servers:
- 123.123.123.123
- 154323
'''
RETURN = r'''
v_switch:
description:
- Information on the vSwitch.
returned: success
type: dict
contains:
id:
description:
- The vSwitch's ID.
type: int
sample: 4321
returned: success
name:
description:
- The vSwitch's name.
type: str
sample: 'my vSwitch'
returned: success
vlan:
description:
- The vSwitch's VLAN ID.
type: int
sample: 4000
returned: success
cancelled:
description:
- Cancellation status.
type: bool
sample: false
returned: success
server:
description:
- The vSwitch's VLAN.
type: list
elements: dict
sample:
- server_ip: '123.123.123.123'
server_ipv6_net: '2a01:4f8:111:4221::'
server_number: 321
status: 'ready'
contains:
server_ip:
description:
- The server's main IP address.
type: str
sample: '123.123.123.123'
server_ipv6_net:
description:
- The server's main IPv6 network address.
type: str
sample: '2a01:f48:111:4221::'
server_number:
description:
- The server's numeric ID.
type: int
sample: 321
status:
description:
- Status of vSwitch for this server.
type: str
choices:
- ready
- in process
- failed
sample: 'ready'
returned: success
subnet:
description:
- List of assigned IP addresses.
type: list
elements: dict
sample:
- ip: '213.239.252.48'
mask: 29
gateway: '213.239.252.49'
contains:
ip:
description:
- IP address.
type: str
sample: '213.239.252.48'
mask:
description:
- Subnet mask in CIDR notation.
type: int
sample: 29
gateway:
description:
- Gateway of the subnet.
type: str
sample: '213.239.252.49'
returned: success
cloud_network:
description:
- List of assigned Cloud networks.
type: list
elements: dict
sample:
- id: 123
ip: '10.0.2.0'
mask: 24
gateway: '10.0.2.1'
contains:
id:
description:
- Cloud network ID.
type: int
sample: 123
ip:
description:
- IP address.
type: str
sample: '10.0.2.0'
mask:
description:
- Subnet mask in CIDR notation.
type: int
sample: 24
gateway:
description:
- Gateway.
type: str
sample: '10.0.2.1'
returned: success
'''
from datetime import datetime
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible_collections.community.hrobot.plugins.module_utils.robot import (
BASE_URL,
ROBOT_DEFAULT_ARGUMENT_SPEC,
get_x_www_form_urlenconded_dict_from_list,
fetch_url_json,
fetch_url_json_with_retries,
CheckDoneTimeoutException,
)
V_SWITCH_BASE_URL = '{0}/vswitch'.format(BASE_URL)
def get_v_switch(module, id_, wait_condition=None):
url = '{0}/{1}'.format(V_SWITCH_BASE_URL, id_)
accept_errors = ['NOT_FOUND']
if wait_condition:
try:
result, error = fetch_url_json_with_retries(
module,
url,
check_done_callback=wait_condition,
check_done_delay=module.params['wait_delay'],
check_done_timeout=module.params['timeout'],
accept_errors=accept_errors,
)
except CheckDoneTimeoutException as dummy:
module.fail_json(msg='Timeout waiting vSwitch operation to finish')
else:
result, error = fetch_url_json(
module,
url,
accept_errors=accept_errors,
)
if error == 'NOT_FOUND':
module.fail_json(msg='vSwitch not found.')
return result
def print_list(possible_list):
if isinstance(possible_list, list):
return [to_native(x) for x in possible_list]
def create_v_switch(module):
headers = {'Content-type': 'application/x-www-form-urlencoded'}
data = {'name': module.params['name'], 'vlan': module.params['vlan']}
result, error = fetch_url_json(
module,
V_SWITCH_BASE_URL,
data=urlencode(data),
headers=headers,
method='POST',
accept_errors=['INVALID_INPUT', 'VSWITCH_LIMIT_REACHED'],
)
if error == 'INVALID_INPUT':
invalid_parameters = print_list(result['error']['invalid'])
module.fail_json(msg='vSwitch invalid parameter ({0})'.format(invalid_parameters))
elif error == 'VSWITCH_LIMIT_REACHED':
module.fail_json(msg='The maximum count of vSwitches is reached')
return result
def delete_v_switch(module, id_):
url = '{0}/{1}'.format(V_SWITCH_BASE_URL, id_)
headers = {'Content-type': 'application/x-www-form-urlencoded'}
data = {'cancellation_date': datetime.now().strftime('%y-%m-%d')}
result, error = fetch_url_json(
module,
url,
data=urlencode(data),
headers=headers,
method='DELETE',
accept_errors=['INVALID_INPUT', 'NOT_FOUND', 'CONFLICT'],
allow_empty_result=True,
)
if error == 'INVALID_INPUT':
invalid_parameters = print_list(result['error']['invalid'])
module.fail_json(msg='vSwitch invalid parameter ({0})'.format(invalid_parameters))
elif error == 'NOT_FOUND':
module.fail_json(msg='vSwitch not found to delete')
elif error == 'CONFLICT':
module.fail_json(msg='The vSwitch is already cancelled')
return result
def is_all_servers_ready(result, dummy):
return all(server['status'] == 'ready' for server in result['server'])
def add_servers(module, id_, servers):
url = '{0}/{1}/server'.format(V_SWITCH_BASE_URL, id_)
headers = {'Content-type': 'application/x-www-form-urlencoded'}
data = get_x_www_form_urlenconded_dict_from_list('server', servers)
result, error = fetch_url_json(
module,
url,
data=urlencode(data),
headers=headers,
method='POST',
# TODO: missing NOT_FOUND, VSWITCH_NOT_AVAILABLE, VSWITCH_PER_SERVER_LIMIT_REACHED
accept_errors=[
'INVALID_INPUT',
'SERVER_NOT_FOUND',
'VSWITCH_VLAN_NOT_UNIQUE',
'VSWITCH_IN_PROCESS',
'VSWITCH_SERVER_LIMIT_REACHED',
],
allow_empty_result=True,
allowed_empty_result_status_codes=(201,),
)
if error == 'INVALID_INPUT':
invalid_parameters = print_list(result['error']['invalid'])
module.fail_json(msg='Invalid parameter adding server ({0})'.format(invalid_parameters))
elif error == 'SERVER_NOT_FOUND':
# information about which servers are failing is only there
module.fail_json(msg=result['error']['message'])
elif error == 'VSWITCH_VLAN_NOT_UNIQUE':
# information about which servers are failing is only there
module.fail_json(msg=result['error']['message'])
elif error == 'VSWITCH_IN_PROCESS':
module.fail_json(msg='There is a update running, therefore the vswitch can not be updated')
elif error == 'VSWITCH_SERVER_LIMIT_REACHED':
module.fail_json(msg='The maximum number of servers is reached for this vSwitch')
# TODO: add and delete with `wait=false`
wait_condition = is_all_servers_ready if module.params['wait'] else None
return get_v_switch(module, id_, wait_condition)
def delete_servers(module, id_, servers):
url = '{0}/{1}/server'.format(V_SWITCH_BASE_URL, id_)
headers = {'Content-type': 'application/x-www-form-urlencoded'}
data = get_x_www_form_urlenconded_dict_from_list('server', servers)
result, error = fetch_url_json(
module,
url,
data=urlencode(data),
headers=headers,
method='DELETE',
# TODO: missing INVALID_INPUT, NOT_FOUND
accept_errors=['SERVER_NOT_FOUND', 'VSWITCH_IN_PROCESS'],
allow_empty_result=True,
)
if error == 'SERVER_NOT_FOUND':
# information about which servers are failing is only there
module.fail_json(msg=result['error']['message'])
elif error == 'VSWITCH_IN_PROCESS':
module.fail_json(msg='There is a update running, therefore the vswitch can not be updated')
wait_condition = is_all_servers_ready if module.params['wait'] else None
return get_v_switch(module, id_, wait_condition)
def get_servers_to_delete(current_servers, desired_servers):
return [
server['server_ip']
for server in current_servers
if server['server_ip'] not in desired_servers
and server['server_ipv6_net'] not in desired_servers
and str(server['server_number']) not in desired_servers
]
def get_servers_to_add(current_servers, desired_servers):
current_ids = [str(server['server_number']) for server in current_servers]
current_ips = [server['server_ip'] for server in current_servers]
current_ipv6s = [server['server_ipv6_net'] for server in current_servers]
return [
server
for server in desired_servers
if server not in current_ips and server not in current_ids and server not in current_ipv6s
]
def set_desired_servers(module, id_):
v_switch = get_v_switch(module, id_)
changed = False
if module.params['servers'] is None:
return (v_switch, changed)
servers_to_delete = get_servers_to_delete(v_switch['server'], module.params['servers'])
if servers_to_delete:
if not module.check_mode:
v_switch = delete_servers(module, id_, servers_to_delete)
changed = True
servers_to_add = get_servers_to_add(v_switch['server'], module.params['servers'])
if servers_to_add:
if not module.check_mode:
v_switch = add_servers(module, id_, servers_to_add)
changed = True
return (v_switch, changed)
def main():
argument_spec = dict(
vlan=dict(type='int', required=True),
name=dict(type='str', required=True),
state=dict(type='str', default='present', choices=['present', 'absent']),
servers=dict(type='list', elements='str'),
wait=dict(type='bool', default=True),
wait_delay=dict(type='int', default=10),
timeout=dict(type='int', default=180),
)
argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
v_switches, error = fetch_url_json(module, V_SWITCH_BASE_URL, accept_errors=['UNAUTHORIZED'])
if error:
module.fail_json(msg='Please check your current user and password configuration')
matched_v_switches = [
v
for v in v_switches
if v['name'] == module.params['name'] and v['vlan'] == module.params['vlan']
]
non_cancelled_v_switches = [m for m in matched_v_switches if m['cancelled'] is False]
result = {'changed': False}
if len(non_cancelled_v_switches) > 1:
module.fail_json(
msg='Multiple vSwitches with same name and VLAN ID in non cancelled status. Clean it.'
)
elif len(non_cancelled_v_switches) == 1:
id_ = non_cancelled_v_switches[0]['id']
v_switch, changed = set_desired_servers(module, id_)
if changed:
result['changed'] = True
if module.params['state'] == 'present':
result['v_switch'] = v_switch
elif module.params['state'] == 'absent':
if not module.check_mode:
delete_v_switch(module, id_)
result['changed'] = True
else:
# not reachable
raise NotImplementedError
else:
if module.params['state'] == 'present':
result['changed'] = True
if not module.check_mode:
v_switch = create_v_switch(module)
if module.params['servers']:
result['v_switch'] = add_servers(module, v_switch['id'], module.params['servers'])
else:
result['v_switch'] = v_switch
module.exit_json(**result)
if __name__ == '__main__': # pragma: no cover
main() # pragma: no cover