diff --git a/ansible_collections/f5networks/f5os/CHANGELOG.rst b/ansible_collections/f5networks/f5os/CHANGELOG.rst index e6f62aa..b17066e 100644 --- a/ansible_collections/f5networks/f5os/CHANGELOG.rst +++ b/ansible_collections/f5networks/f5os/CHANGELOG.rst @@ -4,6 +4,17 @@ F5Networks.F5OS Release Notes .. contents:: Topics +v1.11.0 +======= + +New Modules +----------- + +- f5networks.f5os.f5os_primarykey - Manage F5OS Devices Primary-key Setting. +- f5networks.f5os.f5os_system_image_import - Manage F5OS System image import. +- f5networks.f5os.f5os_system_image_install - Manage F5OS system software installation. +- f5networks.f5os.f5os_tls_cert_key - Manage TLS certificate and key on F5OS devices. + v1.10.1 ======= diff --git a/ansible_collections/f5networks/f5os/changelogs/.plugin-cache.yaml b/ansible_collections/f5networks/f5os/changelogs/.plugin-cache.yaml index 794484b..4844bae 100644 --- a/ansible_collections/f5networks/f5os/changelogs/.plugin-cache.yaml +++ b/ansible_collections/f5networks/f5os/changelogs/.plugin-cache.yaml @@ -20,6 +20,11 @@ plugins: name: f5os_allowed_ips namespace: '' version_added: 1.9.0 + f5os_auth: + description: Manage authentication settings + name: f5os_auth + namespace: '' + version_added: 1.10.0 f5os_config_backup: description: Manage F5OS config backups. name: f5os_config_backup @@ -55,11 +60,21 @@ plugins: name: f5os_lldp_config namespace: '' version_added: 1.8.0 + f5os_logging: + description: Manage logging settings + name: f5os_logging + namespace: '' + version_added: 1.10.0 f5os_ntp_server: description: Manage NTP servers on F5OS based systems name: f5os_ntp_server namespace: '' version_added: 1.8.0 + f5os_primarykey: + description: Manage F5OS Devices Primary-key Setting. + name: f5os_primarykey + namespace: '' + version_added: 1.11.0 f5os_qkview: description: Manage Generation of qkview file name: f5os_qkview @@ -81,6 +96,16 @@ plugins: name: f5os_system namespace: '' version_added: 1.10.0 + f5os_system_image_import: + description: Manage F5OS System image import. + name: f5os_system_image_import + namespace: '' + version_added: 1.11.0 + f5os_system_image_install: + description: Manage F5OS system software installation. + name: f5os_system_image_install + namespace: '' + version_added: 1.11.0 f5os_tenant: description: Manage F5OS tenants name: f5os_tenant @@ -96,6 +121,11 @@ plugins: name: f5os_tenant_wait namespace: '' version_added: 1.0.0 + f5os_tls_cert_key: + description: Manage TLS certificate and key on F5OS devices. + name: f5os_tls_cert_key + namespace: '' + version_added: 1.11.0 f5os_user: description: Manage Users and roles on F5OS based systems name: f5os_user @@ -133,4 +163,4 @@ plugins: strategy: {} test: {} vars: {} -version: 1.10.1 +version: 1.11.0 diff --git a/ansible_collections/f5networks/f5os/changelogs/changelog.yaml b/ansible_collections/f5networks/f5os/changelogs/changelog.yaml index 4364da7..69af6bf 100644 --- a/ansible_collections/f5networks/f5os/changelogs/changelog.yaml +++ b/ansible_collections/f5networks/f5os/changelogs/changelog.yaml @@ -103,6 +103,21 @@ releases: release_date: '2024-08-01' 1.10.1: release_date: '2024-08-01' + 1.11.0: + modules: + - description: Manage F5OS Devices Primary-key Setting. + name: f5os_primarykey + namespace: '' + - description: Manage F5OS System image import. + name: f5os_system_image_import + namespace: '' + - description: Manage F5OS system software installation. + name: f5os_system_image_install + namespace: '' + - description: Manage TLS certificate and key on F5OS devices. + name: f5os_tls_cert_key + namespace: '' + release_date: '2024-09-10' 1.2.0: modules: - description: Manage F5OS config backups. diff --git a/ansible_collections/f5networks/f5os/galaxy.yml b/ansible_collections/f5networks/f5os/galaxy.yml index 36d4b47..1504cdb 100644 --- a/ansible_collections/f5networks/f5os/galaxy.yml +++ b/ansible_collections/f5networks/f5os/galaxy.yml @@ -30,4 +30,4 @@ tags: - networking - rseries - velos -version: 1.10.1 +version: 1.11.0 diff --git a/ansible_collections/f5networks/f5os/plugins/module_utils/version.py b/ansible_collections/f5networks/f5os/plugins/module_utils/version.py index bf90cd1..ec54826 100644 --- a/ansible_collections/f5networks/f5os/plugins/module_utils/version.py +++ b/ansible_collections/f5networks/f5os/plugins/module_utils/version.py @@ -4,4 +4,4 @@ # GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) # This collection version needs to be updated at each release -CURRENT_COLL_VERSION = "1.10.1" +CURRENT_COLL_VERSION = "1.11.0" diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_allowed_ips.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_allowed_ips.py index fe4b678..56beb93 100644 --- a/ansible_collections/f5networks/f5os/plugins/modules/f5os_allowed_ips.py +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_allowed_ips.py @@ -174,9 +174,10 @@ def allowed(self): } for protocol_version in ['ipv6', 'ipv4']: if val[protocol_version] is not None: - result['config'][protocol_version] = dict() - result['config'][protocol_version]['address'] = val[protocol_version]['address'] - result['config'][protocol_version]['prefix-length'] = val[protocol_version]['prefix'] + result['config'][protocol_version] = { + 'address': val[protocol_version]['address'], + 'prefix-length': val[protocol_version]['prefix'] + } if 'port' in val[protocol_version] and val[protocol_version]['port'] is not None: result['config'][protocol_version]['port'] = val[protocol_version]['port'] break @@ -299,14 +300,14 @@ def exec_module(self): def present(self): '''Wrapper for creation/update''' - if self.exists(): + if self.all_exist(): return self.update() else: return self.create() def absent(self): '''Wrapper for removal''' - if self.exists(): + if self.any_exists(): return self.remove() return False @@ -331,7 +332,7 @@ def remove(self) -> bool: if self.module.check_mode: # pragma: no cover return True self.remove_from_device() - if self.exists(): + if self.still_exists(): raise F5ModuleError("Failed to delete the resource.") return True @@ -343,19 +344,34 @@ def create(self) -> bool: self.create_on_device() return True - def exists(self) -> bool: + def any_exists(self): + return self.exists(query='any') + + def all_exist(self): + return self.exists(query='all') + + def still_exists(self): + return self.exists(query='still') + + def exists(self, query=None) -> bool: '''Check object existance on F5OS system''' base_uri = "/openconfig-system:system/f5-allowed-ips:allowed-ips" if (hasattr(self.want, 'allowed') and getattr(self.want, 'allowed') is not None): for val in getattr(self.want, 'allowed'): - object_uri = "/allowed-ip={}".format(val['name']) + object_uri = f'/allowed-ip={val["name"]}' uri = base_uri + object_uri response = self.client.get(uri) + if response['code'] == 200: + if query in ['any', 'still']: + return True if response['code'] == 404: - return False - if response['code'] not in [200, 201, 202]: + if query == 'all': + return False + if response['code'] not in [200, 201, 202, 404]: raise F5ModuleError(response['contents']) + if query in ['any', 'still']: + return False return True def create_on_device(self): @@ -367,8 +383,11 @@ def create_on_device(self): for allow_entry in params['allowed']: payload = {'allowed-ip': [{'name': allow_entry['name'], 'config': allow_entry['config']}]} response = self.client.post(uri, data=payload) + if response['code'] == 409: + # at least one address in the declaration was missing, but not this one. + response = self.client.put(uri + f'/allowed-ip={allow_entry["name"]}', data=payload) if response['code'] not in [200, 201, 202, 204]: - raise F5ModuleError(response['contents']) + raise F5ModuleError(str(response['contents'])) return True @@ -379,7 +398,7 @@ def update_on_device(self): if 'allowed' in params: for allow_entry in params['allowed']: - object_uri = "/allowed-ip={}/config".format(allow_entry['name']) + object_uri = f'/allowed-ip={allow_entry["name"]}/config' uri = base_uri + object_uri payload = {'config': allow_entry['config']} response = self.client.put(uri, data=payload) @@ -394,8 +413,8 @@ def remove_from_device(self): for val in self.want.allowed: uri = f"/openconfig-system:system/f5-allowed-ips:allowed-ips/allowed-ip={val['name']}" response = self.client.delete(uri) - if response['code'] not in [200, 201, 202, 204]: - raise F5ModuleError(response['contents']) + if response['code'] not in [200, 201, 202, 204, 404]: + raise F5ModuleError(str(response['contents'])) return True def read_current_from_device(self): diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_auth.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_auth.py new file mode 100644 index 0000000..999f635 --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_auth.py @@ -0,0 +1,1243 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: f5os_auth +short_description: Manage authentication settings +description: + - Manage authentication settings including + - Remote Auth Servers + - Remote Roles + - Authentication order + - Password Policy + - Please Note This playbook is NOT IDEMPOTENT for API flaws, such as radius + - and tacacs secrets are only reported encrypted and password policy always reports + - as present. For these items, a change is always reported. +version_added: 1.10.0 +options: + servergroups: + description: + - Specifies Server Groups for remote authentication + type: list + elements: dict + suboptions: + name: + description: + - Name of the server group + type: str + protocol: + description: + - authentication protocol for the server group. + - options are [radius, tacacs, ldap, ocsp] + type: str + servers: + description: + - Server list as members of the Server Group + type: list + elements: dict + suboptions: + address: + description: + - Address of the remote host + type: str + port: + description: + - Network Port (TCP/UDP) to be used on the remote server + type: int + security: + description: + - Security setting for LDAP Servers (Applies to LDAP only) + - if present, should be None (LDAP) or "tls" (LDAPS) + type: str + secret: + description: + - Secret for RADIUS or TACACS+ Servers (Applies to RADIUS and TACACS+ only) + type: str + timeout: + description: + - Timeout for RADIUS Servers (Applies to RADIUS only) + default: 3 + type: int + remote_roles: + description: + - Specifies the conditions under which a role is applied to a remote-authenticated user + type: list + elements: dict + suboptions: + rolename: + description: + - Name of the role as configured on the system + - Options are [admin, resource-admin, superuser, operator, user] + type: str + ldap_group: + description: + - Name of the LDAP group (Applies to LDAP only) + type: str + remote_gid: + description: + - Specifies the remote Group ID to be associated with the local role + type: int + auth_order: + description: + - Specifies the order in which the authentication providers are applied to login attempts + - Options are [local, radius, tacacs, ldap] + type: list + elements: str + password_policy: + description: + - Specifies the password policy for local user accounts + type: dict + suboptions: + apply_to_root: + description: + - Specifies if the password policy also applies to the root user + type: bool + max_age: + description: + - Specifies the maximum age for a password + type: int + max_class_repeat: + description: + - Specifies the maximum repetition of Characters within the same class + type: int + max_letter_repeat: + description: + - Specifies the maximum repetition of the same character + type: int + max_login_failures: + description: + - Specifies the maximum logon failures before a user is locked out + type: int + max_retries: + description: + - Specifies the maximum attempts a user can try to create a valid password + type: int + max_sequence_repeat: + description: + - Specifies the maximum repetition of a character sequence + type: int + min_differences: + description: + - Specifies the number of characters that must be altered between updated passwords + type: int + min_length: + description: + - Specifies the minimum password length + type: int + min_lower: + description: + - Specifies the minimum number of lowercase characters + type: int + min_number: + description: + - Specifies the minimum number of numeric characters + type: int + min_special: + description: + - Specifies the minimum number of special character + type: int + min_upper: + description: + - Specifies the minimum number of uppercase characters + type: int + reject_username: + description: + - Specifies whether the system rejects passwords that contain the username + type: bool + root_lockout: + description: + - Specifies whether the root user can be locked out + type: bool + root_unlock_time: + description: + - Specifies the root users unlock time + type: int + unlock_time: + description: + - Specifies the unlock the time + type: int + state: + description: + - If C(present), creates/updates the specified setting if necessary. + - If C(absent), deletes the specified setting if it exists. + type: str + choices: + - present + - absent + default: present +author: + - Martin Vogel (@MVogel91) +''' + +EXAMPLES = r''' +- name: Create Servers + f5os_auth: + servergroups: + - name: radius_servers + protocol: radius + servers: + - address: 10.2.3.4 + secret: TOPSECRET + port: 1812 + timeout: 3 + - address: 10.2.3.5 + secret: TOPSECRET + port: 1812 + timeout: 3 + - name: tacacs_servers + protocol: tacacs + servers: + - address: 10.2.3.4 + secret: TOPSECRET + port: 49 + - address: 10.2.3.5 + secret: TOPSECRET + port: 49 + - name: ldap_servers + protocol: ldap + servers: + - address: 10.2.3.4 + port: 389 + - address: 10.2.3.5 + port: 636 + security: tls + - name: ocsp_servers + protocol: ocsp + servers: + - address: 10.2.3.4 + port: 80 + - address: 10.2.3.5 + port: 80 + +- name: Set Auth Order + f5os_auth: + auth_order: + - radius + - tacacs + - ldap + - local + +- name: Set Password Policy + f5os_auth: + password_policy: + max_age: 30 + max_class_repeat: 2 + max_letter_repeat: 2 + max_login_failures: 10 + max_retries: 3 + max_sequence_repeat: 2 + min_differences: 8 + min_length: 16 + min_lower: 3 + min_number: 3 + min_special: 3 + min_upper: 3 + reject_username: true + root_lockout: false + root_unlock_time: 60 + unlock_time: 60 + +- name: Set Remote Roles + f5os_auth: + remote_roles: + - rolename: admin + remote_gid: 10 + ldap_group: admins + - rolename: resource-admin + remote_gid: 20 + ldap_group: resource-admins + +- name: Delete Servers + f5os_auth: + servergroups: + - name: radius_servers + protocol: radius + servers: + - address: 10.2.3.4 + secret: TOPSECRET + port: 1812 + timeout: 3 + - address: 10.2.3.5 + secret: TOPSECRET + port: 1812 + timeout: 3 + - name: tacacs_servers + protocol: tacacs + servers: + - address: 10.2.3.4 + secret: TOPSECRET + port: 49 + - address: 10.2.3.5 + secret: TOPSECRET + port: 49 + - name: ldap_servers + protocol: ldap + servers: + - address: 10.2.3.4 + port: 389 + - address: 10.2.3.5 + port: 636 + security: tls + - name: ocsp_servers + protocol: ocsp + servers: + - address: 10.2.3.4 + port: 80 + - address: 10.2.3.5 + port: 80 + state: absent + +- name: Set Auth Order + f5os_auth: + auth_order: + - radius + - tacacs + - ldap + - local + state: absent + +- name: Set Password Policy + f5os_auth: + password_policy: + max_age: 30 + max_class_repeat: 2 + max_letter_repeat: 2 + max_login_failures: 10 + max_retries: 3 + max_sequence_repeat: 2 + min_differences: 8 + min_length: 16 + min_lower: 3 + min_number: 3 + min_special: 3 + min_upper: 3 + reject_username: true + root_lockout: false + root_unlock_time: 60 + unlock_time: 60 + state: absent + +- name: Set Remote Roles + f5os_auth: + remote_roles: + - rolename: admin + remote_gid: 10 + ldap_group: admins + - rolename: resource-admin + remote_gid: 20 + ldap_group: resource-admins + state: absent +''' + +RETURN = r''' +servergroups: + description: Specifies the servergroups + returned: changed + type: str +remote_roles: + description: Specifies the remote roles + returned: changed + type: str +auth_order: + description: Specifies the auth order + returned: changed + type: str +password_policy: + description: Specifies the password policy + returned: changed + type: str +''' + +import datetime + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection + +from ..module_utils.client import ( + F5Client, send_teem +) +from ..module_utils.common import ( + F5ModuleError, AnsibleF5Parameters, +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + + } + + api_attributes = [ + 'servergroups', + 'password_policy', + 'auth_order', + 'remote_roles' + ] + + returnables = [ + 'servergroups', + 'password_policy', + 'auth_order', + 'remote_roles' + ] + + updatables = [ + 'servergroups', + 'password_policy', + 'auth_order', + 'remote_roles' + ] + + +class ApiParameters(Parameters): + @property + def servergroups(self): + ''' Restructured API response to match the input pattern. ''' + try: + return_list = list() + for api in self._values['servergroups']: + + # Types Protocols + # - f5-openconfig-aaa-ldap:LDAP -> ldap + # - f5-openconfig-aaa-ocsp:OCSP -> ocsp + # - openconfig-aaa:RADIUS -> radius + # - openconfig-aaa:TACACS -> tacacs + + auth_module = api['config']['type'] + protocol = auth_module.split(":")[1].lower() + return_item = { + 'name': api['name'], + 'protocol': protocol + } + if 'servers' in api: + return_item['servers'] = list() + for server in api['servers']['server']: + server_conf = { + 'address': server['address'], + 'security': None, + 'secret': None, + 'timeout': 3 + } + if protocol in ['radius', 'tacacs']: + auth_name = protocol + elif protocol in ['ldap', 'ocsp']: + auth_name = auth_module.lower() + + if 'port' in server[auth_name]['config']: + server_conf['port'] = server[auth_name]['config']['port'] + elif 'auth-port' in server[auth_name]['config']: + server_conf['port'] = server[auth_name]['config']['auth-port'] + + # add protocol specific settings + if 'type' in server[auth_name]['config']: + if server[auth_name]['config']['type'].lower() == 'f5-openconfig-aaa-ldap:ldaps': + server_conf['security'] = 'tls' + + if 'secret-key' in server[auth_name]['config']: + server_conf['secret'] = server[auth_name]['config']['secret-key'] + + if 'f5-openconfig-aaa-radius:timeout' in server[auth_name]['config']: + server_conf['timeout'] = server[auth_name]['config']['f5-openconfig-aaa-radius:timeout'] + + return_item['servers'].append(server_conf) + + return_list.append(return_item) + + return return_list + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def password_policy(self): + try: + return_value = dict() + api = self._values['password_policy'] + return_value['apply_to_root'] = api.get('apply-to-root') + return_value['max_age'] = api.get('max-age') + return_value['max_class_repeat'] = api.get('max-class-repeat') + return_value['max_letter_repeat'] = api.get('max-letter-repeat') + return_value['max_login_failures'] = api.get('max-login-failures') + return_value['max_retries'] = api.get('retries') + return_value['max_sequence_repeat'] = api.get('max-sequence-repeat') + return_value['min_differences'] = api.get('required-differences') + return_value['min_length'] = api.get('min-length') + return_value['min_lower'] = api.get('required-lowercase') + return_value['min_number'] = api.get('required-numeric') + return_value['min_special'] = api.get('required-special') + return_value['min_upper'] = api.get('required-uppercase') + return_value['reject_username'] = api.get('reject-username') + return_value['root_lockout'] = api.get('root-lockout') + return_value['root_unlock_time'] = api.get('root-unlock-time') + return_value['unlock_time'] = api.get('unlock-time') + return return_value + except (TypeError, ValueError): + return None + + @property + def auth_order(self): + try: + return_value = list() + api = self._values['auth_order'] + for item in api: + if item == 'openconfig-aaa-types:RADIUS_ALL': + return_value.append('radius') + elif item == 'openconfig-aaa-types:TACACS_ALL': + return_value.append('tacacs') + elif item == 'f5-openconfig-aaa-ldap:LDAP_ALL': + return_value.append('ldap') + elif item == 'openconfig-aaa-types:LOCAL': + return_value.append('local') + return return_value + except (TypeError, ValueError): + return None + + @property + def remote_roles(self): + try: + conf_map = { + 'remote-gid': 'remote_gid', + 'ldap-group': 'ldap_group' + } + return_list = list() + for api in self._values['remote_roles']: + return_item = dict() + for attr in api['config']: + if attr in ['description', 'gid']: + # ignore read-only attributes + continue + elif attr in conf_map: + return_item[conf_map[attr]] = api['config'].get(attr) + else: + return_item[attr] = api['config'].get(attr) + + return_list.append(return_item) + + return return_list + except (TypeError, ValueError): + return None + + +class ModuleParameters(Parameters): + pass + + +class Changes(Parameters): # pragma: no cover + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): # pragma: no cover + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + want = getattr(self.want, param) + try: + have = getattr(self.have, param) + if want != have: + return want + except AttributeError: + return want + + @property + def password_policy(self): + if self.want.password_policy is None: + return None + result = { + 'password_policy': dict() + } + password_policy = result['password_policy'] + identical = True + for val in self.want.password_policy: + if self.want.password_policy[val] is not None: + password_policy[val] = self.want.password_policy[val] + if self.want.password_policy[val] == self.have.password_policy.get(val): + continue + else: + identical = False + if identical: + return None + else: + return result + + @property + def remote_roles(self): + if self.want.remote_roles is None: + return None + resultset = { + 'remote_roles': list() + } + identical = True + for wrole in self.want.remote_roles: + result = dict() + for hrole in self.have.remote_roles: + if wrole['rolename'] == hrole['rolename']: + for val in wrole: + if wrole[val] is not None: + result[val] = wrole[val] + if wrole[val] == hrole.get(val): + continue + else: + identical = False + resultset['remote_roles'].append(result) + if identical: + return None + else: + return resultset + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.have = ApiParameters() + + def _set_password_policy(self, params): + '''Helper for creating / updating password policy as both scenarios use the same + method and syntax''' + uri = '/openconfig-system:system/aaa/f5-openconfig-aaa-password-policy:password-policy' + payload = { + 'f5-openconfig-aaa-password-policy:password-policy': { + 'config': dict() + } + } + config = payload['f5-openconfig-aaa-password-policy:password-policy']['config'] + + values = params['password_policy'] + conf_map = { + 'apply_to_root': 'apply-to-root', + 'max_age': 'max-age', + 'max_class_repeat': 'max-class-repeat', + 'max_letter_repeat': 'max-letter-repeat', + 'max_login_failures': 'max-login-failures', + 'max_retries': 'retries', + 'max_sequence_repeat': 'max-sequence-repeat', + 'min_differences': 'required-differences', + 'min_length': 'min-length', + 'min_lower': 'required-lowercase', + 'min_number': 'required-numeric', + 'min_special': 'required-special', + 'min_upper': 'required-uppercase', + 'reject_username': 'reject-username', + 'root_lockout': 'root-lockout', + 'root_unlock_time': 'root-unlock-time', + 'unlock_time': 'unlock-time', + } + for attr in conf_map: + if attr in values and values[attr] is not None: + config[conf_map[attr]] = values[attr] + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + def _set_auth_order(self, params): + '''Helper for creating / updating auth_order as both scenarios use the same + method and syntax''' + uri = '/openconfig-system:system/aaa/authentication/config/authentication-method' + payload = { + "openconfig-system:authentication-method": list() + } + config = payload['openconfig-system:authentication-method'] + + values = params['auth_order'] + conf_map = { + 'radius': 'openconfig-aaa-types:RADIUS_ALL', + 'tacacs': 'openconfig-aaa-types:TACACS_ALL', + 'ldap': 'f5-openconfig-aaa-ldap:LDAP_ALL', + 'local': 'openconfig-aaa-types:LOCAL' + } + for item in values: + config.append(conf_map[item]) + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + def _set_remote_roles(self, params): + '''Helper for creating / updating remote roles as both scenarios use the same + method and syntax''' + for remote_role in params['remote_roles']: + uri = f'/openconfig-system:system/aaa/authentication/f5-system-aaa:roles/role="{remote_role["rolename"]}"' + payload = { + 'f5-system-aaa:role': [ + { + 'rolename': remote_role['rolename'], + 'config': dict() + } + ] + } + config = payload['f5-system-aaa:role'][0]['config'] + + conf_map = { + 'remote_gid': 'remote-gid', + 'ldap_group': 'ldap-group' + } + for attr in remote_role: + if remote_role[attr] is not None: + if attr in conf_map: + config[conf_map[attr]] = remote_role[attr] + else: + config[attr] = remote_role[attr] + + response = self.client.patch(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): # pragma: no cover + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def present(self): + if self.all_exist(): + return self.update() + else: + return self.create() + + def absent(self): + if self.any_exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: # pragma: no cover + return True + self.update_on_device() + return True + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.still_exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def any_exists(self): + return self.exists(query='any') + + def all_exist(self): + return self.exists(query='all') + + def still_exists(self): + return self.exists(query='still') + + def exists(self, query=None): + if hasattr(self.want, 'servergroups') and self.want.servergroups is not None: + for servergroup in self.want.servergroups: + uri = f'/openconfig-system:system/aaa/server-groups/server-group="{servergroup["name"]}"' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] == 404: + if query == 'all': + return False + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'password_policy') and self.want.password_policy is not None: + if hasattr(self.want, 'password_policy') and self.want.password_policy is not None: + uri = '/openconfig-system:system/aaa/f5-openconfig-aaa-password-policy:password-policy' + response = self.client.get(uri) + if response['code'] != 200: + # Password Policy always exists + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'auth_order') and self.want.auth_order is not None: + uri = '/openconfig-system:system/aaa/authentication/config/authentication-method' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] == 404: + if query == 'all': + return False + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'remote_roles') and self.want.remote_roles is not None: + for remote_role in self.want.remote_roles: + uri = f'/openconfig-system:system/aaa/authentication/f5-system-aaa:roles/role="{remote_role["rolename"]}"' + response = self.client.get(uri) + if response['code'] == 200: + # Password Policy always exists. + # After resetting with delete, it fails the removal check + config = response['contents']['f5-system-aaa:role'][0]['config'] + if 'remote-gid' in config or 'ldap-group' in config: + # Non-Default config + if query in ['any', 'still']: + return True + else: + if query == 'all': + return False + else: + raise F5ModuleError(response['contents']) + + if query == 'still': + return False + if query == 'any': + if hasattr(self.want, 'password_policy') and self.want.password_policy is not None: + return True + else: + return False + return True + + def create_on_device(self): + params = self.changes.api_params() + + if 'servergroups' in params and params['servergroups'] is not None: + for servergroup in params['servergroups']: + name = servergroup['name'] + protocol = servergroup['protocol'] + + uri = '/openconfig-system:system/aaa/server-groups' + payload = { + 'openconfig-system:server-group': { + 'name': name, + 'config': { + 'name': name + } + } + } + properties = payload['openconfig-system:server-group'] + config = properties['config'] + + if 'servers' in servergroup: + properties['servers'] = { + 'server': list() + } + servers = properties['servers']['server'] + + for server in servergroup['servers']: + server_conf = { + 'address': server['address'], + 'config': { + 'address': server['address'] + } + } + if protocol == 'ldap': + if server['security'] == 'tls': + server_type = 'ldaps' + else: + server_type = 'ldap' + + server_conf['f5-openconfig-aaa-ldap:ldap'] = { + 'config': { + 'auth-port': server['port'], + 'type': 'f5-openconfig-aaa-ldap:' + server_type + } + } + elif protocol == 'ocsp': + server_conf['f5-openconfig-aaa-ocsp:ocsp'] = { + 'config': { + 'port': server['port'] + } + } + elif protocol == 'radius': + server_conf[protocol] = { + 'config': { + 'auth-port': server['port'], + 'secret-key': server['secret'], + 'f5-openconfig-aaa-radius:timeout': server['timeout'] + } + } + elif protocol == 'tacacs': + server_conf[protocol] = { + 'config': { + 'port': server['port'], + 'secret-key': server['secret'] + } + } + servers.append(server_conf) + + if protocol == 'ldap': + config['type'] = 'f5-openconfig-aaa-ldap:LDAP' + elif protocol == 'ocsp': + config['type'] = 'f5-openconfig-aaa-ocsp:OCSP' + elif protocol == 'radius': + config['type'] = 'openconfig-aaa:RADIUS' + elif protocol == 'tacacs': + config['type'] = 'openconfig-aaa:TACACS' + + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + if 'remote_roles' in params and params['remote_roles'] is not None: + self._set_remote_roles(params) + + if 'password_policy' in params and params['password_policy'] is not None: + self._set_password_policy(params) + + if 'auth_order' in params and params['auth_order'] is not None: + self._set_auth_order(params) + + return True + + def update_on_device(self): + params = self.changes.api_params() + + if 'servergroups' in params and params['servergroups'] is not None: + for servergroup in params['servergroups']: + name = servergroup['name'] + protocol = servergroup['protocol'] + + uri = f'/openconfig-system:system/aaa/server-groups/server-group="{name}"' + payload = { + 'openconfig-system:server-group': { + 'name': name, + 'config': { + 'name': name + } + } + } + properties = payload['openconfig-system:server-group'] + config = properties['config'] + + if 'servers' in servergroup: + properties['servers'] = { + 'server': list() + } + servers = properties['servers']['server'] + + for server in servergroup['servers']: + server_conf = { + 'address': server['address'], + 'config': { + 'address': server['address'] + } + } + if protocol == 'ldap': + if server['security'] == 'tls': + server_type = 'ldaps' + else: + server_type = 'ldap' + + server_conf['f5-openconfig-aaa-ldap:ldap'] = { + 'config': { + 'auth-port': server['port'], + 'type': 'f5-openconfig-aaa-ldap:' + server_type + } + } + elif protocol == 'ocsp': + server_conf['f5-openconfig-aaa-ocsp:ocsp'] = { + 'config': { + 'port': server['port'] + } + } + elif protocol == 'radius': + server_conf[protocol] = { + 'config': { + 'auth-port': server['port'], + 'secret-key': server['secret'], + 'f5-openconfig-aaa-radius:timeout': server['timeout'] + } + } + elif protocol == 'tacacs': + server_conf[protocol] = { + 'config': { + 'port': server['port'], + 'secret-key': server['secret'] + } + } + servers.append(server_conf) + + if protocol == 'ldap': + config['type'] = 'f5-openconfig-aaa-ldap:LDAP' + elif protocol == 'ocsp': + config['type'] = 'f5-openconfig-aaa-ocsp:OCSP' + elif protocol == 'radius': + config['type'] = 'openconfig-aaa:RADIUS' + elif protocol == 'tacacs': + config['type'] = 'openconfig-aaa:TACACS' + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + if 'remote_roles' in params and params['remote_roles'] is not None: + self._set_remote_roles(params) + + if 'password_policy' in params and params['password_policy'] is not None: + self._set_password_policy(params) + + if 'auth_order' in params and params['auth_order'] is not None: + self._set_auth_order(params) + + return True + + def remove_from_device(self): + if hasattr(self.want, 'servergroups') and self.want.servergroups is not None: + for servergroup in self.want.servergroups: + uri = f'/openconfig-system:system/aaa/server-groups/server-group="{servergroup["name"]}"' + response = self.client.delete(uri) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'password_policy') and self.want.password_policy is not None: + uri = '/openconfig-system:system/aaa/f5-openconfig-aaa-password-policy:password-policy' + response = self.client.delete(uri) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'auth_order') and self.want.auth_order is not None: + uri = '/openconfig-system:system/aaa/authentication/config/authentication-method' + response = self.client.delete(uri) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'remote_roles') and self.want.remote_roles is not None: + for remote_role in self.want.remote_roles: + uri = f'/openconfig-system:system/aaa/authentication/f5-system-aaa:roles/role="{remote_role["rolename"]}"/config/remote-gid' + response = self.client.delete(uri) + if response['code'] not in [200, 201, 202, 204, 404]: + raise F5ModuleError(response['contents']) + + uri = f'/openconfig-system:system/aaa/authentication/f5-system-aaa:roles/role="{remote_role["rolename"]}"/config/ldap-group' + response = self.client.delete(uri) + if response['code'] not in [200, 201, 202, 204, 404]: + raise F5ModuleError(response['contents']) + + def read_current_from_device(self): + params = dict() + + # Servergroup + if hasattr(self.want, 'servergroups') and self.want.servergroups is not None: + params['servergroups'] = list() + for servergroup in self.want.servergroups: + uri = f'/openconfig-system:system/aaa/server-groups/server-group="{servergroup["name"]}"' + servergroup_response = self.client.get(uri) + if servergroup_response['code'] == 404: + # add empty object + params['servergroups'] = params['servergroups'] + [{'name': servergroup["name"]}] + elif servergroup_response['code'] not in [200, 201, 202]: + raise F5ModuleError(servergroup_response['contents']['openconfig-system:server-group']) + else: + params['servergroups'] = params['servergroups'] + (servergroup_response['contents']['openconfig-system:server-group']) + + # Password Policy + if hasattr(self.want, 'password_policy') and self.want.password_policy is not None: + uri = '/openconfig-system:system/aaa/f5-openconfig-aaa-password-policy:password-policy' + password_policy_response = self.client.get(uri) + if password_policy_response['code'] not in [200, 201, 202]: + raise F5ModuleError(password_policy_response['contents']['f5-openconfig-aaa-password-policy:password-policy']) + + params['password_policy'] = password_policy_response['contents']['f5-openconfig-aaa-password-policy:password-policy']['config'] + + # Auth Config order + if hasattr(self.want, 'auth_order') and self.want.auth_order is not None: + uri = '/openconfig-system:system/aaa/authentication/config/authentication-method' + auth_order_response = self.client.get(uri) + if auth_order_response['code'] not in [200, 201, 202]: + raise F5ModuleError(auth_order_response['contents']['openconfig-system:authentication-method']) + + params['auth_order'] = auth_order_response['contents']['openconfig-system:authentication-method'] + + # Remote Roles + if hasattr(self.want, 'remote_roles') and self.want.remote_roles is not None: + params['remote_roles'] = list() + for remote_role in self.want.remote_roles: + uri = f'/openconfig-system:system/aaa/authentication/f5-system-aaa:roles/role="{remote_role["rolename"]}"' + remote_roles_response = self.client.get(uri) + if remote_roles_response['code'] not in [200, 201, 202]: + raise F5ModuleError(remote_roles_response['contents']['f5-system-aaa:role']) + params['remote_roles'] = params['remote_roles'] + remote_roles_response['contents']['f5-system-aaa:role'] + + return ApiParameters(params=params) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + servergroups=dict( + type='list', + elements='dict', + options=dict( + name=dict(type='str'), + protocol=dict(type='str'), + servers=dict( + type='list', + elements='dict', + options=dict( + address=dict(type='str'), + security=dict(type='str'), + port=dict(type='int'), + secret=dict(type='str', no_log=True), + timeout=dict(type='int', default=3) + ) + ) + ) + ), + password_policy=dict( + type='dict', + no_log=False, + options=dict( + apply_to_root=dict(type='bool'), + max_age=dict(type='int'), + max_class_repeat=dict(type='int'), + max_letter_repeat=dict(type='int'), + max_login_failures=dict(type='int'), + max_retries=dict(type='int'), + max_sequence_repeat=dict(type='int'), + min_differences=dict(type='int'), + min_length=dict(type='int'), + min_lower=dict(type='int'), + min_number=dict(type='int'), + min_special=dict(type='int'), + min_upper=dict(type='int'), + reject_username=dict(type='bool'), + root_lockout=dict(type='bool'), + root_unlock_time=dict(type='int'), + unlock_time=dict(type='int') + ) + ), + auth_order=dict( + type='list', + elements='str' + ), + remote_roles=dict( + type='list', + elements='dict', + options=dict( + rolename=dict(type='str'), + ldap_group=dict(type='str'), + remote_gid=dict(type='int') + ) + ), + state=dict( + default='present', + choices=['present', 'absent'] + ), + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + self.required_one_of = [('servergroups', 'password_policy', 'auth_config', 'remote_roles')] + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_logging.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_logging.py new file mode 100644 index 0000000..b3388cd --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_logging.py @@ -0,0 +1,1052 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: f5os_logging +short_description: Manage logging settings +description: + - Enable / disable remote logging + - Specify to include hostname + - Specify remote servers + - Specify logs and files to forward to the remote server + - Specify TLS settings (cert, key, trusted CA) for mTLS + - This Module is not idempotent due to API restrictions +version_added: 1.10.0 +options: + servers: + description: Specifies the logservers + type: list + elements: dict + suboptions: + address: + description: Specifies the servers IP address. + type: str + port: + description: Specifies the transport layer port + type: int + protocol: + description: Specifies the transport layer protocol + type: str + choices: + - tcp + - udp + authentication: + description: + - Specifies if the system uses mutual TLS to transfer logs encrypted and authenticated. + - The client certificate and key are to be specified in the tls parameter. + - Only applies for protocol(tcp). + type: bool + logs: + description: Specifies the logs to be sent to this specific server + type: list + elements: dict + suboptions: + facility: + description: Filter logs on facility local0 or authpriv. + type: str + severity: + description: Specify the minimum seceverity to be forwarded to this server + type: str + choices: + - debug + - informational + - notice + - warning + - error + - critical + - alert + - emergency + remote_forwarding: + description: Specifies logs and files for remote forwarding + type: dict + suboptions: + enabled: + description: Enables remote log forwarding + type: bool + logs: + description: Specifies the logs to be sent to remote servers + type: list + elements: dict + suboptions: + facility: + description: Filter logs on facility. + type: str + severity: + description: Specify the minimum seceverity to be forwarded to remote servers + type: str + choices: + - debug + - informational + - notice + - warning + - error + - critical + - alert + - emergency + files: + description: Specifies the files to be sent to remote servers + type: list + elements: dict + suboptions: + name: + description: Specifies the file path (starting from the log directory) that shall be forwarded + type: str + include_hostname: + description: Specifies whether or not to include the hostname in the logmessages + type: bool + tls: + description: Specifies the TLS certificate and key for mutual TLS with TCP log forwarding + type: dict + suboptions: + certificate: + description: Specifies the TLS certificate + type: str + key: + description: Specifies the TLS key + type: str + ca_bundles: + description: Specifies the trusted CA bundles for mutual TLS with TCP log forwarding + type: list + elements: dict + suboptions: + name: + description: Specifies the name for the bundle + type: str + content: + description: Specifies certificate files in PEM format + type: str + state: + description: + - If C(present), creates/updates the specified setting if necessary. + - If C(absent), deletes the specified setting if it exists. + type: str + choices: + - present + - absent + default: present +author: + - Martin Vogel (@MVogel91) +''' + +EXAMPLES = r''' +- name: Configure TLS settings + f5os_logging: + tls: + certificate: + key: + ca_bundles: + - name: "test" + content: + - name: "test2" + content: + +- name: Create logservers + f5os_logging: + servers: + - address: 1.2.3.4 + protocol: udp + port: 514 + logs: + - facility: local0 + severity: notice + - facility: authpriv + severity: notice + - address: 1.2.3.5 + protocol: udp + port: 514 + logs: + - facility: local0 + severity: notice + - facility: authpriv + severity: notice + +- name: Send hostname + f5os_logging: + include_hostname: true + +- name: Configure Remote Forwarding + f5os_logging: + remote_forwarding: + enabled: true + logs: + - facility: local0 + severity: informational + - facility: authpriv + severity: notice + - facility: auth + severity: emergency + files: + - name: ansible.log + - name: audit/ + - name: boot.log + +- name: Remove logservers + f5os_logging: + servers: + - address: 1.2.3.4 + protocol: udp + port: 514 + logs: + - facility: local0 + severity: notice + - facility: authpriv + severity: notice + - address: 1.2.3.5 + protocol: udp + port: 514 + logs: + - facility: local0 + severity: notice + - facility: authpriv + severity: notice + state: absent + +- name: Disable sending of hostname + f5os_logging: + include_hostname: false + +- name: Remove Remote Forwarding config + f5os_logging: + remote_forwarding: + enabled: true + logs: + - facility: local0 + severity: informational + - facility: authpriv + severity: notice + - facility: auth + severity: emergency + files: + - name: ansible.log + - name: audit/ + - name: boot.log + state: absent + +- name: Remove TLS settings + f5os_logging: + tls: + certificate: + key: + ca_bundles: + - name: "test" + content: + - name: "test2" + content: + state: absent +''' + +RETURN = r''' +tls: + description: TLS settings + returned: changed + type: str +ca_bundles: + description: CA bundles + returned: changed + type: str +servers: + description: Remote Log server configs + returned: changed + type: str +include_hostname: + description: inclusion of hostname in logs + returned: changed + type: str +remote_forwarding: + description: forwarding settings for log files + returned: changed + type: str +''' + +import datetime + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection + +from ..module_utils.client import ( + F5Client, send_teem +) +from ..module_utils.common import ( + F5ModuleError, AnsibleF5Parameters, +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + + } + + api_attributes = [ + 'tls', + 'ca_bundles', + 'servers', + 'include_hostname', + 'remote_forwarding' + ] + + returnables = [ + 'tls', + 'ca_bundles', + 'servers', + 'include_hostname', + 'remote_forwarding' + ] + + updatables = [ + 'tls', + 'ca_bundles', + 'servers', + 'include_hostname', + 'remote_forwarding' + ] + + +class ApiParameters(Parameters): + @property + def servers(self): + try: + result_set = [] + for server in self._values["servers"]: + conf = server["config"] + server_conf = { + 'address': conf["host"], + 'port': conf["remote-port"], + 'protocol': conf["f5-openconfig-system-logging:proto"] + } + if 'f5-openconfig-system-logging:authentication' in conf: + server_conf['authentication'] = conf['f5-openconfig-system-logging:authentication']['enabled'] + else: + server_conf['authentication'] = None + if 'selectors' in server: + server_conf['logs'] = list() + for selector in server['selectors']['selector']: + log_conf = { + 'facility': selector['facility'].split(":")[1].lower(), + 'severity': selector['severity'].lower() + } + server_conf['logs'].append(log_conf) + result_set.append(server_conf) + return result_set + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def remote_forwarding(self): + try: + values = self._values['remote_forwarding'] + resultset = { + 'enabled': values['remote-forwarding']['enabled'] + } + if 'selectors' in values: + resultset['logs'] = list() + for log in values['selectors']['selector']: + log_conf = { + 'facility': log['facility'].split(":")[1].lower(), + 'severity': log['severity'].lower() + } + resultset['logs'].append(log_conf) + if 'files' in values: + # no changes needed + resultset['files'] = values['files']['file'] + return resultset + except (TypeError, ValueError): + return None + + @property + def ca_bundles(self): + try: + resultset = list() + for bundle in self._values['ca_bundles']: + resultset.append(bundle['config']) + return resultset + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + +class ModuleParameters(Parameters): + pass + + +class Changes(Parameters): # pragma: no cover + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): # pragma: no cover + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + want = getattr(self.want, param) + try: + have = getattr(self.have, param) + if want != have: + return want + except AttributeError: + return want + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.have = ApiParameters() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def present(self): + if self.all_exist(): + return self.update() + else: + return self.create() + + def absent(self): + if self.any_exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: # pragma: no cover + return True + self.update_on_device() + return True + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.still_exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def any_exists(self): + return self.exists(query='any') + + def all_exist(self): + return self.exists(query='all') + + def still_exists(self): + return self.exists(query='still') + + def exists(self, query=None): + base_uri = '/openconfig-system:system/logging' + + if hasattr(self.want, 'servers') and self.want.servers is not None: + for server in self.want.servers: + uri = f'{base_uri}/remote-servers/remote-server="{server["address"]}"' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] == 404: + if query in ['all']: + return False + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'include_hostname') and self.want.include_hostname is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:config' + response = self.client.get(uri) + + if response['code'] not in [200, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'remote_forwarding') and self.want.remote_forwarding is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:host-logs' + response = self.client.get(uri) + + if response['code'] != 200: + # Host-logs always exists + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'tls') and self.want.tls is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:tls' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + elif response['code'] == 204: + if query in ['all']: + return False + else: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'ca_bundles') and self.want.ca_bundles is not None: + for bundle in self.want.ca_bundles: + uri = f'{base_uri}/f5-openconfig-system-logging:tls/ca-bundles/ca-bundle={bundle["name"]}' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + elif response['code'] == 404: + if query in ['all']: + return False + else: + raise F5ModuleError(response['contents']) + + if query == 'still': + return False + if query == 'any': + if hasattr(self.want, 'include_hostname') and self.want.include_hostname is not None: + return True + else: + return False + return True + + def create_on_device(self): + params = self.changes.api_params() + base_uri = '/openconfig-system:system/logging' + + if 'tls' in params and params['tls'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:tls' + payload = { + 'f5-openconfig-system-logging:tls': params['tls'] + } + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 204]: + raise F5ModuleError(response['contents']) + + if 'ca_bundles' in params and params['ca_bundles'] is not None: + for bundle in params['ca_bundles']: + uri = f'{base_uri}/f5-openconfig-system-logging:tls/ca-bundles' + payload = { + 'ca-bundle': { + 'name': bundle["name"], + 'config': bundle + } + } + + response = self.client.post(uri, data=payload) + if response['code'] == 409: + # This object exists already, so override it + put_uri = f'{uri}/ca-bundle="{bundle["name"]}"' + response = self.client.put(put_uri, data=payload) + + if response['code'] not in [200, 201, 204]: + raise F5ModuleError(response['contents']) + + if 'remote_forwarding' in params and params['remote_forwarding'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:host-logs' + payload = { + 'f5-openconfig-system-logging:host-logs': { + 'config': dict() + } + } + conf = payload['f5-openconfig-system-logging:host-logs']['config'] + conf['remote-forwarding'] = { + 'enabled': params['remote_forwarding']['enabled'] + } + if 'logs' in params['remote_forwarding'] and params['remote_forwarding']['logs'] is not None: + conf['selectors'] = { + 'selector': list() + } + for log in params['remote_forwarding']['logs']: + log_conf = { + 'facility': f'openconfig-system-logging:{log["facility"].upper()}', + 'severity': log['severity'].upper() + } + conf['selectors']['selector'].append(log_conf) + + if 'files' in params['remote_forwarding'] and params['remote_forwarding']['files'] is not None: + conf['files'] = { + 'file': list() + } + for file in params['remote_forwarding']['files']: + file_conf = { + 'name': file['name'] + } + conf['files']['file'].append(file_conf) + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 204]: + raise F5ModuleError(response['contents']) + + if 'servers' in params and params['servers'] is not None: + for server in params['servers']: + uri = f'{base_uri}/remote-servers/' + payload = { + 'remote-server': list() + } + server_list = payload['remote-server'] + server_conf = { + 'host': server['address'], + 'config': { + 'host': server['address'], + 'remote-port': server['port'], + 'f5-openconfig-system-logging:proto': server['protocol'] + } + } + if 'authentication' in server and server['authentication'] is not None: + server_conf['config'] = { + 'f5-openconfig-system-logging:authentication': { + 'enabled': server['authentication'] + } + } + if 'logs' in server and server['logs'] is not None: + server_conf['selectors'] = { + 'selector': list() + } + for log in server['logs']: + log_conf = { + 'facility': f'f5-system-logging-types:{log["facility"].upper()}', + 'severity': log['severity'].upper(), + 'config': { + 'facility': f'f5-system-logging-types:{log["facility"].upper()}', + 'severity': log['severity'].upper(), + } + } + server_conf['selectors']['selector'].append(log_conf) + server_list.append(server_conf) + + response = self.client.post(uri, data=payload) + if response['code'] == 409: + # This object exists already, so override it + put_uri = f'{uri}/remote-server="{server["address"]}"' + response = self.client.put(put_uri, data=payload) + + if response['code'] not in [200, 201, 204]: + raise F5ModuleError(response['contents']) + + if 'include_hostname' in params and params['include_hostname'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:config' + payload = { + 'f5-openconfig-system-logging:config': { + 'include-hostname': params['include_hostname'] + } + } + response = self.client.put(uri, data=payload) + + if response['code'] not in [200, 201, 204]: + raise F5ModuleError(response['contents']) + + return True + + def update_on_device(self): + params = self.changes.api_params() + base_uri = '/openconfig-system:system/logging' + + if 'tls' in params and params['tls'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:tls' + payload = { + 'f5-openconfig-system-logging:tls': params['tls'] + } + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 204]: + raise F5ModuleError(response['contents']) + + if 'ca_bundles' in params and params['ca_bundles'] is not None: + for bundle in params['ca_bundles']: + uri = f'{base_uri}/f5-openconfig-system-logging:tls/ca-bundles/ca-bundle="{bundle["name"]}"' + payload = { + 'ca-bundle': { + 'name': bundle["name"], + 'config': bundle + } + } + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 201, 204]: + raise F5ModuleError(response['contents']) + + if 'remote_forwarding' in params and params['remote_forwarding'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:host-logs/config' + payload = { + 'config': dict() + } + conf = payload['config'] + conf['remote-forwarding'] = { + 'enabled': params['remote_forwarding']['enabled'] + } + if 'logs' in params['remote_forwarding'] and params['remote_forwarding']['logs'] is not None: + conf['selectors'] = { + 'selector': list() + } + for log in params['remote_forwarding']['logs']: + log_conf = { + 'facility': f'openconfig-system-loggin:{log["facility"].upper()}', + 'severity': log['severity'].upper() + } + conf['selectors']['selector'].append(log_conf) + + if 'files' in params['remote_forwarding'] and params['remote_forwarding']['files'] is not None: + conf['files'] = { + 'file': list() + } + for file in params['remote_forwarding']['files']: + file_conf = { + 'name': file['name'] + } + conf['files']['file'].append(file_conf) + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 204]: + raise F5ModuleError(response['contents']) + + if 'servers' in params and params['servers'] is not None: + uri = f'{base_uri}/remote-servers' + payload = { + 'openconfig-system:remote-servers': { + 'remote-server': list() + } + } + server_list = payload['openconfig-system:remote-servers']['remote-server'] + for server in params['servers']: + server_conf = { + 'host': server['address'], + 'config': { + 'host': server['address'], + 'remote-port': server['port'], + 'f5-openconfig-system-logging:proto': server['protocol'] + } + } + + if 'authentication' in server and server['authentication'] is not None: + server_conf['config'] = { + 'f5-openconfig-system-logging:authentication': { + 'enabled': server['authentication'] + } + } + + if 'logs' in server and server['logs'] is not None: + server_conf['selectors'] = { + 'selector': list() + } + for log in server['logs']: + log_conf = { + 'facility': f'f5-system-logging-types:{log["facility"].upper()}', + 'severity': log['severity'].upper(), + 'config': { + 'facility': f'f5-system-logging-types:{log["facility"].upper()}', + 'severity': log['severity'].upper(), + } + } + server_conf['selectors']['selector'].append(log_conf) + server_list.append(server_conf) + + response = self.client.put(uri, data=payload) + if response['code'] not in [200, 204]: + raise F5ModuleError(response['contents']) + + if 'include_hostname' in params and params['include_hostname'] is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:config' + payload = { + 'f5-openconfig-system-logging:config': { + 'include-hostname': params['include_hostname'] + } + } + response = self.client.put(uri, data=payload) + + if response['code'] not in [200, 201, 204]: + raise F5ModuleError(response['contents']) + + return True + + def remove_from_device(self): + base_uri = '/openconfig-system:system/logging' + + if hasattr(self.want, 'servers') and self.want.servers is not None: + for server in self.want.servers: + uri = f'{base_uri}/remote-servers/remote-server="{server["address"]}"' + response = self.client.delete(uri) + + if response['code'] not in [200, 204, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'include_hostname') and self.want.include_hostname is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:config' + response = self.client.delete(uri) + + if response['code'] not in [200, 204, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'remote_forwarding') and self.want.remote_forwarding is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:host-logs' + response = self.client.delete(uri) + + if response['code'] not in [200, 204, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'tls') and self.want.tls is not None: + for attribute in ['certificate', 'key']: + uri = f'{base_uri}/f5-openconfig-system-logging:tls/{attribute}' + response = self.client.delete(uri) + + if response['code'] not in [200, 204, 404]: + raise F5ModuleError(response['contents']) + + if hasattr(self.want, 'ca_bundles') and self.want.ca_bundles is not None: + for bundle in self.want.ca_bundles: + uri = f'{base_uri}/f5-openconfig-system-logging:tls/ca-bundles/ca-bundle={bundle["name"]}' + response = self.client.delete(uri) + + if response['code'] not in [200, 204, 404]: + raise F5ModuleError(response['contents']) + + def read_current_from_device(self): + params = dict() + base_uri = '/openconfig-system:system/logging' + + # Servers + if hasattr(self.want, 'servers') and self.want.servers is not None: + params['servers'] = list() + for server in self.want.servers: + uri = f'{base_uri}/remote-servers/remote-server="{server["address"]}"' + server_response = self.client.get(uri) + if server_response['code'] == 404: + continue + elif server_response['code'] not in [200, 201, 202]: + raise F5ModuleError(server_response['contents']['openconfig-system:remote-server']) + else: + params['servers'] = params['servers'] + (server_response['contents']['openconfig-system:remote-server']) + + # include hostname + if hasattr(self.want, 'include_hostname') and self.want.include_hostname is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:config' + response = self.client.get(uri) + + if response['code'] not in [200, 404]: + raise F5ModuleError(response['contents']) + if response['code'] == 200: + params['include_hostname'] = response['contents']['f5-openconfig-system-logging:config']['include-hostname'] + + # Remote Forwarding + if hasattr(self.want, 'remote_forwarding') and self.want.remote_forwarding is not None: + uri = f'{base_uri}/f5-openconfig-system-logging:host-logs' + response = self.client.get(uri) + + if response['code'] != 200: + raise F5ModuleError(response['contents']) + else: + params['remote_forwarding'] = response['contents']['f5-openconfig-system-logging:host-logs']['config'] + + # TLS Cert, Key and CA bundles + if (hasattr(self.want, 'tls') and self.want.tls is not None) or \ + (hasattr(self.want, 'ca_bundles') and self.want.ca_bundles is not None): + uri = f'{base_uri}/f5-openconfig-system-logging:tls' + response = self.client.get(uri) + + if response['code'] != 200: + raise F5ModuleError(response['contents']) + + content = response['contents']['f5-openconfig-system-logging:tls'] + if 'certificate' in content or 'key' in content: + tls = content.copy() + del tls['ca-bundles'] + params['tls'] = tls + if 'ca-bundles' in content: + ca_bundles = content['ca-bundles']['ca-bundle'] + params['ca_bundles'] = ca_bundles + + return ApiParameters(params=params) + + +class ArgumentSpec(object): + severities = ['debug', 'informational', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'] + + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + servers=dict( + type='list', + elements='dict', + options=dict( + address=dict(type='str'), + port=dict(type='int'), + protocol=dict( + type='str', + choices=['tcp', 'udp'] + ), + authentication=dict(type='bool'), + logs=dict( + type='list', + elements='dict', + options=dict( + facility=dict(type='str'), + severity=dict( + type='str', + choices=ArgumentSpec.severities + ) + ) + ) + ) + ), + remote_forwarding=dict( + type='dict', + options=dict( + enabled=dict(type='bool'), + logs=dict( + type='list', + elements='dict', + options=dict( + facility=dict(type='str'), + severity=dict( + type='str', + choices=ArgumentSpec.severities + ) + ) + ), + files=dict( + type='list', + elements='dict', + options=dict( + name=dict(type='str') + ) + ) + ) + ), + include_hostname=dict( + type='bool' + ), + tls=dict( + type='dict', + options=dict( + certificate=dict(type='str'), + key=dict( + type='str', + no_log=True + ) + ) + ), + ca_bundles=dict( + type='list', + elements='dict', + options=dict( + name=dict(type='str'), + content=dict(type='str') + ) + ), + state=dict( + default='present', + choices=['present', 'absent'] + ), + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_primarykey.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_primarykey.py new file mode 100644 index 0000000..95884f4 --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_primarykey.py @@ -0,0 +1,320 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: f5os_primarykey +short_description: Manage F5OS Devices Primary-key Setting. +description: + - Manage Setting system primary-key using C(passphrase) and C(salt) on F5OS Devices. +version_added: "1.11.0" +options: + passphrase: + description: + - Specifies Passphrase for generating primary key. + required: True + type: str + salt: + description: + - Specifies Salt for generating primary key. + required: True + type: str + state: + description: + - Primary key on F5OS Device state. + - If C(present), Creates/Set the Primary key on F5OS Device. + type: str + choices: + - present + - absent + default: present +author: + - Ravinder Reddy (@chinthalapalli) +''' + +EXAMPLES = r''' +- name: Setting Primary Key on F5OS Device + f5os_primarykey: + passphrase: "test-passphrase" + salt: "test-salt" + state: present +''' + +RETURN = r''' +passphrase: + description: Specifies Passphrase for generating primary key. + returned: changed + type: str + sample: "test-passphrase" +salt: + description: Specifies Salt for generating primary key. + returned: changed + type: str + sample: "test-salt" +''' + +import datetime +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection + +from ansible_collections.f5networks.f5os.plugins.module_utils.client import ( + F5Client, send_teem +) +from ansible_collections.f5networks.f5os.plugins.module_utils.common import ( + F5ModuleError, AnsibleF5Parameters +) + + +class Parameters(AnsibleF5Parameters): + api_map = {} + + api_attributes = [ + 'passphrase', + 'salt' + ] + returnables = [ + 'passphrase', + 'salt' + ] + updatables = returnables + + +class ApiParameters(Parameters): + pass + + +class ModuleParameters(Parameters): + @property + def passphrase(self): + result = self._values['passphrase'] + if result is None: + return None + return result + + @property + def salt(self): + result = self._values['salt'] + if result is None: + return None + return result + + +class Changes(Parameters): # pragma: no cover + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): # pragma: no cover + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: + return attr1 + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.have = ApiParameters() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): # pragma: no cover + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): # pragma: no cover + changed.update(change) + else: + changed[k] = change + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def present(self): + if not self.exists(): + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + if not self.should_update(): + return False + if self.module.check_mode: # pragma: no cover + return True + self.update_on_device() + return True + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def exists(self): + if self.want.passphrase is None and self.want.salt is None: + return False + response = self.client.get("/openconfig-system:system/aaa/f5-primary-key:primary-key") + if response['code'] == 404: + return False + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + if 'state' in response['contents']['f5-primary-key:primary-key']: + if response['contents']['f5-primary-key:primary-key']['state']['status'].find("COMPLETE") != -1: + return True + return False + + def create_on_device(self): + params = self.changes.api_params() + # we use name parameter separately in UsableChanges + uri = "/openconfig-system:system/aaa/f5-primary-key:primary-key/f5-primary-key:set" + payload = { + "f5-primary-key:passphrase": params['passphrase'], + "f5-primary-key:confirm-passphrase": params['passphrase'], + "f5-primary-key:salt": params['salt'], + "f5-primary-key:confirm-salt": params['salt'] + } + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + time.sleep(60) + return True + + def remove_from_device(self): + uri = "/openconfig-system:system/aaa/f5-primary-key:primary-key" + response = self.client.delete(uri) + if response['code'] == 404: + return False + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + return True + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + passphrase=dict(type='str', no_log=True, required=True), + salt=dict(type='str', no_log=True, required=True), + state=dict( + default='present', + choices=['present', 'absent'] + ), + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_system.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system.py index 1d35f13..0aea854 100644 --- a/ansible_collections/f5networks/f5os/plugins/modules/f5os_system.py +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system.py @@ -32,10 +32,60 @@ description: - Specifies the timezone for the system per TZ database name type: str + cli_timeout: + description: + - Specifies the CLI idle timeout + type: int + httpd_ciphersuite: + description: + - Specifies the httpd ciphersuite in OpenSSL format + type: str + sshd_idle_timeout: + description: + - Specifies the SSHD idle timeout + type: str + sshd_ciphers: + description: + - Specifies the sshd ciphers in OpenSSH format + type: list + elements: str + sshd_kex_alg: + description: + - Specifies the sshd key exchange algorithems in OpenSSH format + type: list + elements: str + sshd_mac_alg: + description: + - Specifies the sshd MAC algorithems in OpenSSH format + type: list + elements: str + sshd_hkey_alg: + description: + - Specifies the sshd host key algorithems in OpenSSH format + type: list + elements: str + gui_advisory: + description: + - Specify the GUI advisory banner + type: dict + suboptions: + color: + description: + - Specify the color of the advisory banner + type: str + choices: + - blue + - green + - orange + - red + - yellow + text: + description: + - Specify the text for the advisory banner + type: str state: description: - - State for the settings. Please note, this is kept for future additions and currently - - unused as implemented settings can't be removed. + - State for the settings. - If C(present), creates/updates the specified setting if necessary. - If C(absent), deletes the specified setting if it exists. type: str @@ -54,6 +104,29 @@ motd: Todays weather is great! login_banner: With great power comes great responsibility timezone: UTC + cli_timeout: 3600 + sshd_idle_timeout: 1800 + httpd_ciphersuite: ECDHE-RSA-AES256-GCM-SHA384 + sshd_ciphers: + - aes256-ctr + - aes256-gcm@openssh.com + sshd_kex_alg: + - ecdh-sha2-nistp384 + - ecdh-sha2-nistp521 + sshd_mac_alg: + - hmac-sha1 + - hmac-sha1-96 + sshd_hkey_alg: + - ssh-rsa + +- name: Unset MAC / Host Key algorithms + f5os_system: + sshd_hkey_alg: + - ssh-rsa + sshd_mac_alg: + - hmac-sha1 + - hmac-sha1-96 + state: absent ''' RETURN = r''' @@ -61,22 +134,50 @@ description: Specifies the system hostname returned: changed type: str - sample: system.example.net motd: description: Specifies the message of the day returned: changed type: str - sample: Todays weather is great! login_banner: - description: Specifies the Specifies the Login Banner + description: Specifies the Login Banner returned: changed type: str - sample: With great power comes great responsibility timezone: description: Specifies the timezone for the system per TZ database name returned: changed type: str - sample: UTC +cli_timeout: + description: Specifies the CLI idle timeout + returned: changed + type: str +httpd_ciphersuite: + description: Specifies the httpd ciphersuite in OpenSSL format + returned: changed + type: str +sshd_idle_timeout: + description: Specifies the SSHD idle timeout + returned: changed + type: str +sshd_ciphers: + description: Specifies the sshd ciphers in OpenSSH format + returned: changed + type: list +sshd_kex_alg: + description: Specifies the sshd key exchange algorithems in OpenSSH format + returned: changed + type: list +sshd_mac_alg: + description: Specifies the sshd MAC algorithems in OpenSSH format + returned: changed + type: list +sshd_hkey_alg: + description: Specifies the sshd host key algorithems in OpenSSH format + returned: changed + type: list +gui_advisory: + description: Specifies GUI advisory banner + returned: changed + type: str ''' import datetime @@ -101,21 +202,45 @@ class Parameters(AnsibleF5Parameters): 'timezone', 'motd', 'login_banner', - 'hostname' + 'hostname', + 'cli_timeout', + 'sshd_idle_timeout', + 'httpd_ciphersuite', + 'sshd_ciphers', + 'sshd_kex_alg', + 'sshd_mac_alg', + 'sshd_hkey_alg', + 'gui_advisory' ] returnables = [ 'timezone', 'motd', 'login_banner', - 'hostname' + 'hostname', + 'cli_timeout', + 'sshd_idle_timeout', + 'httpd_ciphersuite', + 'sshd_ciphers', + 'sshd_kex_alg', + 'sshd_mac_alg', + 'sshd_hkey_alg', + 'gui_advisory' ] updatables = [ 'timezone', 'motd', 'login_banner', - 'hostname' + 'hostname', + 'cli_timeout', + 'sshd_idle_timeout', + 'httpd_ciphersuite', + 'sshd_ciphers', + 'sshd_kex_alg', + 'sshd_mac_alg', + 'sshd_hkey_alg', + 'gui_advisory' ] @@ -124,29 +249,125 @@ class ApiParameters(Parameters): def timezone(self): try: return self._values['clock']['config']['timezone-name'] - except (TypeError, ValueError): + except (TypeError, ValueError, KeyError): return None @property def motd(self): try: return self._values['config']['motd-banner'] - except (TypeError, ValueError): + except (TypeError, ValueError, KeyError): return None @property def login_banner(self): try: return self._values['config']['login-banner'] - except (TypeError, ValueError): + except (TypeError, ValueError, KeyError): return None @property def hostname(self): try: return self._values['config']['hostname'] + except (TypeError, ValueError, KeyError): + return None + + @property + def cli_timeout(self): + try: + return int(self._values['settings']['config']['idle-timeout']) + except (TypeError, ValueError, KeyError): + return None + + @property + def sshd_idle_timeout(self): + try: + return self._values['settings']['config']['sshd-idle-timeout'] + except (TypeError, ValueError, KeyError): + return None + + @property + def httpd_ciphersuite(self): + try: + for service in self._values['ciphers']: + if service['name'] == 'httpd': + return service['config']['ssl-ciphersuite'] + return None + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def sshd_ciphers(self): + try: + for service in self._values['ciphers']: + if service['name'] == 'sshd': + sorted_ciphers = service['config']['ciphers'] + sorted_ciphers.sort() + return sorted_ciphers + return None + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def sshd_kex_alg(self): + try: + for service in self._values['ciphers']: + if service['name'] == 'sshd': + sorted_kex = service['config']['kexalgorithms'] + sorted_kex.sort() + return sorted_kex + return None + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def sshd_mac_alg(self): + try: + for service in self._values['ciphers']: + if service['name'] == 'sshd': + sorted_macs = service['config']['macs'] + sorted_macs.sort() + return sorted_macs + return None + except (TypeError, ValueError): + return None + except (KeyError): + return [] + + @property + def sshd_hkey_alg(self): + try: + for service in self._values['ciphers']: + if service['name'] == 'sshd': + sorted_hkey_algs = service['config']['host-key-algorithms'] + sorted_hkey_algs.sort() + return sorted_hkey_algs + return None except (TypeError, ValueError): return None + except (KeyError): + return [] + + @property + def gui_advisory(self): + try: + config = self._values['settings']['f5-gui-advisory:gui']['advisory']['config'] + result = { + 'color': config['color'], + 'text': config['text'] + } + return result + except (TypeError, ValueError): + return None + except (KeyError): + return [] class ModuleParameters(Parameters): @@ -222,10 +443,7 @@ def _update_changed_options(self): if change is None: continue else: - if isinstance(change, dict): # pragma: no cover - changed.update(change) - else: - changed[k] = change + changed[k] = change if changed: self.changes = UsableChanges(params=changed) return True @@ -259,13 +477,13 @@ def exec_module(self): return result def present(self): - if self.exists(): + if self.all_exist(): return self.update() else: return self.create() def absent(self): - if self.exists(): + if self.any_exists(): return self.remove() return False @@ -288,7 +506,7 @@ def remove(self): if self.module.check_mode: # pragma: no cover return True self.remove_from_device() - if self.exists(): + if self.still_exists(): raise F5ModuleError("Failed to delete the resource.") return True @@ -299,20 +517,101 @@ def create(self): self.create_on_device() return True - def exists(self): - uri = "/openconfig-system:system" - response = self.client.get(uri) + def any_exists(self): + return self.exists(query='any') - if response['code'] == 404: - return False + def all_exist(self): + return self.exists(query='all') - if response['code'] not in [200, 201, 202]: - raise F5ModuleError(response['contents']) + def still_exists(self): + return self.exists(query='still') + + def exists(self, query=None): + conf_attr = { + 'login_banner': 'login-banner', + 'motd': 'motd-banner', + 'hostname': 'hostname' + } + for attr in conf_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/config/{conf_attr[attr]}' + response = self.client.get(uri) + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + clock_attr = { + 'timezone': 'timezone-name' + } + for attr in clock_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/clock/config/{clock_attr[attr]}' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + settings_attr = { + 'cli_timeout': 'idle-timeout', + 'sshd_idle_timeout': 'sshd-idle-timeout', + 'gui_advisory': 'f5-gui-advisory:gui' + } + for attr in settings_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/f5-system-settings:settings/{settings_attr[attr]}' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + ciphers_attr = { + 'httpd_ciphersuite': 'ssl-cipher-suite', + 'sshd_ciphers': 'ciphers', + 'sshd_kex_alg': 'kexalgorithms', + 'sshd_mac_alg': 'macs', + 'sshd_hkey_alg': 'host-key-algorithms' + } + for attr in ciphers_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + if attr == 'httpd_ciphersuite': + uri = '/openconfig-system:system/f5-security-ciphers:security/services/service="httpd"/config/ssl-ciphersuite' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + else: + uri = f'/openconfig-system:system/f5-security-ciphers:security/services/service="sshd"/config/{ciphers_attr[attr]}' + response = self.client.get(uri) + + if response['code'] == 200: + if query in ['any', 'still']: + return True + + if response['code'] not in [200, 201, 202, 404]: + raise F5ModuleError(response['contents']) + + if query in ['any', 'still']: + return False return True def create_on_device(self): - # not applicable for system parameters + # not applicable for system parameters, pass def update_on_device(self): @@ -329,31 +628,203 @@ def update_on_device(self): config['hostname'] = params['hostname'] if 'timezone' in params: # Clock is nested - system['clock'] = dict() - system['clock']['config'] = dict() - system['clock']['config']['timezone-name'] = params['timezone'] + system['clock'] = { + 'config': { + 'timezone-name': params['timezone'] + } + } if 'motd' in params: config['motd-banner'] = params['motd'] if 'login_banner' in params: config['login-banner'] = params['login_banner'] + # Settings use a different API endpoint + if any(attr in ['cli_timeout', 'sshd_idle_timeout', 'gui_advisory'] for attr in params): + settings_uri = '/openconfig-system:system/f5-system-settings:settings' + settings_payload = { + 'settings': { + 'config': dict() + } + } + + settings_config = settings_payload['settings']['config'] + if 'cli_timeout' in params: + settings_config['idle-timeout'] = params['cli_timeout'] + if 'sshd_idle_timeout' in params: + settings_config['sshd-idle-timeout'] = params['sshd_idle_timeout'] + if 'gui_advisory' in params: + settings_payload['settings']['f5-gui-advisory:gui'] = { + 'advisory': { + 'config': { + 'color': params['gui_advisory']['color'], + 'text': params['gui_advisory']['text'], + 'enabled': True + } + } + } + + settings_response = self.client.patch(settings_uri, data=settings_payload) + if settings_response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(settings_response['contents']) + + # Ciphers + Key Exchange use a different API endpoint + if 'httpd_ciphersuite' in params: + httpd_uri = '/openconfig-system:system/f5-security-ciphers:security/services/service="httpd"/config' + httpd_payload = { + 'config': { + 'name': 'httpd', + 'ssl-ciphersuite': params['httpd_ciphersuite'] + } + } + httpd_response = self.client.put(httpd_uri, data=httpd_payload) + if httpd_response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(httpd_response['contents']) + + if any(attr in ['sshd_ciphers', 'sshd_kex_alg', 'sshd_mac_alg', 'sshd_hkey_alg'] for attr in params): + sshd_uri = '/openconfig-system:system/f5-security-ciphers:security/services/service="sshd"/config' + + attributes = {} + + if hasattr(self.want, 'sshd_ciphers') and self.want.sshd_ciphers is not None: + attributes['ciphers'] = {'ciphers': self.want.sshd_ciphers} + if hasattr(self.want, 'sshd_kex_alg') and self.want.sshd_kex_alg is not None: + attributes['kexalgorithms'] = {'kexalgorithms': self.want.sshd_kex_alg} + if hasattr(self.want, 'sshd_mac_alg') and self.want.sshd_mac_alg is not None: + attributes['macs'] = {'macs': self.want.sshd_mac_alg} + if hasattr(self.want, 'sshd_hkey_alg') and self.want.sshd_hkey_alg is not None: + attributes['host-key-algorithms'] = {'host-key-algorithms': self.want.sshd_hkey_alg} + + for attr in attributes: + sshd_response = self.client.put(sshd_uri + "/" + attr, data=attributes[attr]) + if sshd_response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(sshd_response['contents']) + response = self.client.patch(uri, data=payload) if response['code'] not in [200, 201, 202, 204]: raise F5ModuleError(response['contents']) return True def remove_from_device(self): - # not applicable for system parameters - pass + conf_attr = { + 'login_banner': 'login-banner', + 'motd': 'motd-banner', + 'hostname': 'hostname' + } + for attr in conf_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/config/{conf_attr[attr]}' + response = self.client.delete(uri) + + if response['code'] == 204: + # Deleted + continue + elif response['code'] == 404: + # Not Found + continue + else: + raise F5ModuleError(response['contents']) + + clock_attr = { + 'timezone': 'timezone-name' + } + for attr in clock_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/clock/config/{clock_attr[attr]}' + response = self.client.delete(uri) + + if response['code'] == 204: + # Deleted + continue + elif response['code'] == 404: + # Not Found + continue + else: + raise F5ModuleError(response['contents']) + + settings_attr = { + 'cli_timeout': 'idle-timeout', + 'sshd_idle_timeout': 'sshd-idle-timeout' + } + for attr in settings_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + uri = f'/openconfig-system:system/f5-system-settings:settings/{settings_attr[attr]}' + response = self.client.delete(uri) + + if response['code'] == 204: + # Deleted + continue + elif response['code'] == 404: + # Not Found + continue + else: + raise F5ModuleError(response['contents']) + + ciphers_attr = { + 'httpd_ciphersuite': 'ssl-cipher-suite', + 'sshd_ciphers': 'ciphers', + 'sshd_kex_alg': 'kexalgorithms', + 'sshd_mac_alg': 'macs', + 'sshd_hkey_alg': 'host-key-algorithms' + } + for attr in ciphers_attr: + if hasattr(self.want, attr) and getattr(self.want, attr) is not None: + if attr == 'httpd_ciphersuite': + uri = '/openconfig-system:system/f5-security-ciphers:security/services/service="httpd"/config/ssl-ciphersuite' + response = self.client.delete(uri) + + if response['code'] == 204: + # Deleted + continue + elif response['code'] == 404: + # Not Found + continue + else: + raise F5ModuleError(response['contents']) + + else: + uri = f'/openconfig-system:system/f5-security-ciphers:security/services/service="sshd"/config/{ciphers_attr[attr]}' + response = self.client.delete(uri) + + if response['code'] == 204: + # Deleted + continue + elif response['code'] == 404: + # Not Found + continue + else: + raise F5ModuleError(response['contents']) def read_current_from_device(self): - uri = "/openconfig-system:system" - response = self.client.get(uri) + params = dict() + # Motd, login_banner, hostname + uri = "/openconfig-system:system/config" + response = self.client.get(uri) if response['code'] not in [200, 201, 202]: - raise F5ModuleError(response['contents']['openconfig-system:system']) - - params = response['contents']['openconfig-system:system'] + raise F5ModuleError(response['contents']['openconfig-system:config']) + + # Clock + clock_uri = "/openconfig-system:system/clock" + clock_response = self.client.get(clock_uri) + if clock_response['code'] not in [200, 201, 202]: + raise F5ModuleError(clock_response['contents']['openconfig-system:clock']) + + # Ciphers + ciphers_uri = '/openconfig-system:system/f5-security-ciphers:security/services/service' + ciphers_response = self.client.get(ciphers_uri) + if ciphers_response['code'] not in [200, 201, 202]: + raise F5ModuleError(ciphers_response['contents']['f5-security-ciphers:service']) + + # Settings + settings_uri = '/openconfig-system:system/f5-system-settings:settings' + settings_response = self.client.get(settings_uri) + if settings_response['code'] not in [200, 201, 202]: + raise F5ModuleError(settings_response['contents']['f5-system-settings:settings']) + + params['config'] = response['contents']['openconfig-system:config'] + params['clock'] = clock_response['contents']['openconfig-system:clock'] + params['ciphers'] = ciphers_response['contents']['f5-security-ciphers:service'] + params['settings'] = settings_response['contents']['f5-system-settings:settings'] return ApiParameters(params=params) @@ -365,6 +836,41 @@ def __init__(self): login_banner=dict(type='str'), motd=dict(type='str'), timezone=dict(type='str'), + gui_advisory=dict( + type='dict', + options=dict( + color=dict( + type='str', + choices=[ + 'blue', + 'green', + 'orange', + 'red', + 'yellow' + ] + ), + text=dict(type='str') + ) + ), + cli_timeout=dict(type='int'), + httpd_ciphersuite=dict(type='str'), + sshd_idle_timeout=dict(type='str'), + sshd_ciphers=dict( + type='list', + elements='str' + ), + sshd_kex_alg=dict( + type='list', + elements='str' + ), + sshd_mac_alg=dict( + type='list', + elements='str' + ), + sshd_hkey_alg=dict( + type='list', + elements='str' + ), state=dict( default='present', choices=['present', 'absent'] diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_import.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_import.py new file mode 100644 index 0000000..c40f19f --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_import.py @@ -0,0 +1,512 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2024, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +# https://my.f5.com/manage/s/article/K93807441 + +DOCUMENTATION = r''' +--- +module: f5os_system_image_import +short_description: Manage F5OS System image import. +description: + - Manage the import of system images onto F5OS devices. +version_added: "1.11.0" +options: + remote_image_url: + description: + - The path/url to the system image on the remote server. + type: str + required: true + remote_user: + description: + - Provide the remote system username where the system image is stored. + type: str + remote_password: + description: + - Provide the remote system password where the system image is stored. + type: str + local_path: + description: + - The path on the F5OS where the the system image will be imported. + type: str + choices: + - "images/import" + - "images/staging" + - "images/tenant" + - images + operation_id: + description: + - The import operation ID of the image import task. + type: str + timeout: + description: + - The number of seconds to wait for image import to finish. + - The accepted value range is between C(150) and C(3600) seconds. + type: int + default: 300 + state: + description: + - The tenant image state. + - If C(import), starts the image import task if the image does not exist. + - If C(present), checks for the status of the import operation if the image does not exist. + - If C(absent), deletes the system image if it exists. + type: str + choices: + - import + - present + - absent + default: import +notes: + - Repeating the same image import task immediately after the previous is not idempotent + if the image has not finished downloading. +author: + - Ravinder Reddy (@chinthalapalli) +''' + +EXAMPLES = r''' +- name: Import system image 'foo' onto the F5OS device + f5os_system_image_import: + remote_image_url: https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso + local_path: images/staging + state: import + +- name: Check the status of the image import onto the F5OS device + f5os_system_image_import: + remote_image_url: https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso + local_path: images/staging + operation_id: IMPORT-lZsT6P7M + timeout: 600 + state: present + +- name: Remove system image 'F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso' from the F5OS device + f5os_system_image_import: + remote_image_url: https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso + state: absent +''' +RETURN = r''' +remote_image_url: + description: The path/url to the system image on the remote server. + returned: changed + type: str + example: https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso +operation_id: + description: Operation ID of the image import task. + returned: changed + type: str + example: IMPORT-lZsT6P7M +local_path: + description: The path on the F5OS where the the system image will be imported. + returned: changed + type: str + example: images/staging +''' + +import datetime +import time +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection + +from ansible_collections.f5networks.f5os.plugins.module_utils.client import ( + F5Client, send_teem +) + +from ansible_collections.f5networks.f5os.plugins.module_utils.common import ( + F5ModuleError, AnsibleF5Parameters +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'remote-url': 'remote_image_url', + 'local-file': 'local_path', + 'username': 'remote_user', + 'password': 'remote_password', + 'image-name': 'image_name', + } + api_attributes = [ + 'remote-url', + 'local-file', + 'username', + 'password', + 'image-name', + ] + + returnables = [ + 'remote_image_url', + 'local_path', + 'image_name', + 'remote_user', + 'message', + 'operation_id', + ] + + updatables = [] + + +class ModuleParameters(Parameters): + @property + def timeout(self): + divisor = 100 + timeout = self._values['timeout'] + if timeout < 150 or timeout > 3600: + raise F5ModuleError( + "Timeout value must be between 150 and 3600 seconds." + ) + + delay = timeout / divisor + + return delay, divisor + + @property + def image_name(self): + if self._values['remote_image_url'] is None: + return None + return self._values['remote_image_url'].split('/')[-1] + + @property + def remote_image_url(self): + if self._values['remote_image_url'] is None: + return None + return self._values['remote_image_url'] + + +class Changes(Parameters): + def to_return(self): # pragma: no cover + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + returnables = [ + 'remote_image_url', + 'local_path', + 'remote_user', + 'operation_id', + ] + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.image_is_valid = False + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + # if self.client.platform == 'Velos Controller': + # raise F5ModuleError("Target device is a VELOS controller, aborting.") + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + + if state == "import": + changed = self.import_image() + elif state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def import_image(self): + if self.exists(): + return False + else: + return self.create() + + def present(self): + if self.exists(): + if self.image_is_valid: + return False + return True + + def absent(self): + if self.exists(): + return self.remove() + return False + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def exists(self): + uri = "/f5-utils-file-transfer:file/list" + self.image_exist = False + if self.want.operation_id and self.want.state == 'import': + raise F5ModuleError("when operation_id is provided state must be not import.") + if self.want.operation_id and self.want.state == 'present': + return self.import_status_complete() + payload = { + "f5-utils-file-transfer:path": "images/staging" + } + response = self.client.post(uri, data=payload) + if response['code'] == 404: + return False + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + for item in response['contents']['f5-utils-file-transfer:output']['entries']: + if item['name'] == self.want.image_name: + self.image_exist = True + self.image_is_valid = True + break + return self.image_exist + + def create_on_device(self): + params = self.changes.api_params() + uri = "/f5-utils-file-transfer:file/import" + params['insecure'] = "" + if 'username' in params and params['username'] == "": + del params['username'] + if 'password' in params and params['password'] == "": + del params['password'] + del params['image-name'] + payload = dict(input=[params]) + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(f"Failed to import system image with {response['contents']}") + if 'f5-utils-file-transfer:output' in response['contents'] and 'result' in response['contents']['f5-utils-file-transfer:output']: + result = response['contents']['f5-utils-file-transfer:output']['result'] + if result.startswith('Aborted: local-file already exists'): + raise F5ModuleError(f"Failed to import system image, error: {result}") + if result.startswith('File import with same local file name is in progress'): + raise F5ModuleError(f"Failed to import system image, error: {result}") + operation_id = response['contents']['f5-utils-file-transfer:output']['operation-id'] + self.changes.update({"operation_id": operation_id}) + time.sleep(20) + self.changes.update({"message": f"Image {self.want.image_name} import started."}) + return True + + def import_status_complete(self): + delay, period = self.want.timeout + for x in range(0, period): + if self.is_still_uploading(): + time.sleep(delay) + continue + if not self.is_imported(): + time.sleep(delay) + continue + if not self.changes.message: + self.changes.update({"message": f"Image {self.want.image_name} import successful."}) + return True + raise F5ModuleError( + "Module timeout reached, state change is unknown, " + "please increase the timeout parameter for long lived actions." + ) + + def is_still_uploading(self): + uri = "/f5-utils-file-transfer:file/transfer-operations/transfer-operation" + response = self.client.get(uri) + if response['code'] == 204: + return False + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + for item in response['contents']['f5-utils-file-transfer:transfer-operation']: + # check if the operation-id is exist in the response + if 'operation-id' not in item: + continue + if item['operation-id'] == self.want.operation_id: + status = item['status'].strip() + if status == 'Completed': + return False + elif status.startswith('In Progress') or status.startswith('File Transfer Initiated'): + return True + else: + raise F5ModuleError(f"File upload failed with the following result: {status}") + raise F5ModuleError("File upload job not has not started, check device logs for more information.") + + def is_imported(self): + uri = "/openconfig-system:system/f5-system-image:image/f5-system-image:state/f5-system-image:iso/f5-system-image:iso" + if "CONTROLLER" in self.want.image_name: + # Check the status of the image for F5OS-C Controller + uri = "/f5-system-image:image/controller/state/controllers/controller" + if 'PARTITION' in self.want.image_name: + # Check the status of the image for F5OS-C Partition + uri = "/f5-system-image:image/partition/state/controllers/controller" + response = self.client.get(uri) + # raise F5ModuleError(response['contents']) + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + pattern = r'(\d+\.\d+\.\d+-\d+)' + # Search for the pattern in the filename + match = re.search(pattern, self.want.image_name) + img_str = match.group(1) + status = "" + if "CONTROLLER" in self.want.image_name: + images = response['contents']['f5-system-image:controller'] + for image in images: + for isoimg in image['iso']['iso']: + if isoimg['version-iso-controller'] == img_str: + status = isoimg['status'] + break + if 'ready' in status: + return True + if 'verifying' in status: + return False + if 'verification-failed' in status: + raise F5ModuleError(f"The image: {self.want.image_name} was imported, but it failed signature verification, " + f"remove the image and try again.") + return False + if 'PARTITION' in self.want.image_name: + images = response['contents']['f5-system-image:controller'] + for image in images: + for isoimg in image['iso']['iso']: + if isoimg['version-iso-partition'] == img_str: + status = isoimg['status'] + break + if 'ready' in status: + return True + if 'verifying' in status: + return False + if 'verification-failed' in status: + raise F5ModuleError(f"The image: {self.want.image_name} was imported, but it failed signature verification, " + f"remove the image and try again.") + return False + images = response['contents']['f5-system-image:iso'] + for image in images: + if image['version-iso'] == img_str: + status = image['status'] + break + if 'ready' in status: + time.sleep(10) + return True + if 'verifying' in status: + return False + if 'verification-failed' in status: + raise F5ModuleError(f"The image: {self.want.image_name} was imported, but it failed signature verification, " + f"remove the image and try again.") + return False + + def remove_from_device(self): + uri = "/openconfig-system:system/f5-system-image:image/remove" + pattern = r'(\d+\.\d+\.\d+-\d+)' + # Search for the pattern in the filename + match = re.search(pattern, self.want.image_name) + img_str = match.group(1) + if "CONTROLLER" in self.want.image_name: + # Remove the image for F5OS-C Controller + uri = "/f5-system-image:image/controller/remove" + if 'PARTITION' in self.want.image_name: + # Remove the image for F5OS-C Partition + uri = "/f5-system-image:image/partition/remove" + # payload_keys = ["iso", "os", "service"] + payload_keys = ["iso"] + success = False + result = "" + for key in payload_keys: + payload = { + key: img_str, + } + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(f"Failed to remove system {key} image: {self.want.image_name} {response['contents']}") + result = response['contents']["f5-system-image:output"]['response'] + time.sleep(10) + if 'Success' in result: + success = True + else: + success = False + if success: + return True + raise F5ModuleError(f"Failed to remove system image: {self.want.image_name} {result}") + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + remote_image_url=dict(type='str', required=True), + remote_user=dict(), + remote_password=dict(no_log=True), + local_path=dict( + choices=['images/import', 'images/staging', 'images/tenant', 'images'] + ), + operation_id=dict(type='str'), + timeout=dict( + type='int', + default=300 + ), + state=dict( + default='import', + choices=['import', 'present', 'absent'] + ), + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + self.required_if = [ + ['state', 'import', ['remote_image_url', 'local_path']], + ['state', 'present', ['remote_image_url', 'local_path']], + ] + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + required_if=spec.required_if + ) + + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_install.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_install.py new file mode 100644 index 0000000..1047fbc --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_system_image_install.py @@ -0,0 +1,360 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2024, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: f5os_system_image_install +short_description: Manage F5OS system software installation. +description: + - Manage F5OS system software installation. +version_added: "1.11.0" +options: + image_version: + description: + - Image/software version to be installed on the F5OS device. + type: str + required: true + timeout: + description: + - The number of seconds to wait for software installation to complete. + - The accepted value range is between C(150) and C(3600) seconds. + type: int + default: 300 + state: + description: + - If C(install), starts the installation of the system image on the F5OS device. + - If C(present), checks for the status of the installation of the system image and waits for completion. + - If C(absent), presently this option is not supported.It will not remove the image from the device/uninstall the image. + type: str + choices: + - install + - present + - absent + default: install +notes: + - Presently, the C(absent) option is not supported. It will not remove the image from the device/uninstall the image. +author: + - Ravinder Reddy (@chinthalapalli) +''' + +EXAMPLES = r''' +- name: Install Software Image + f5os_system_image_install: + image_version: "1.8.0-13846" + state: install + +- name: check status of Image Install + f5os_system_image_install: + image_version: "1.8.0-13846" + state: present + timeout: 600 +''' +RETURN = r''' +image_version: + description: Image/software version to be installed on the F5OS device. + returned: changed + type: str + example: 1.8.0-13846 +''' + +import datetime +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.connection import Connection + +from ansible_collections.f5networks.f5os.plugins.module_utils.client import ( + F5Client, send_teem +) + +from ansible_collections.f5networks.f5os.plugins.module_utils.common import ( + F5ModuleError, AnsibleF5Parameters +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + 'image-name': 'image_version', + } + api_attributes = [ + 'image-name', + ] + + returnables = [ + 'image_version', + 'message', + ] + + updatables = [] + + +class ModuleParameters(Parameters): + @property + def timeout(self): + divisor = 100 + timeout = self._values['timeout'] + if timeout < 150 or timeout > 3600: + raise F5ModuleError( + "Timeout value must be between 150 and 3600 seconds." + ) + + delay = timeout / divisor + + return delay, divisor + + @property + def image_version(self): + if self._values['image_version'] is None: + return None + return self._values['image_version'] + + +class Changes(Parameters): + def to_return(self): # pragma: no cover + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + returnables = [ + 'image_version', + ] + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.image_is_valid = False + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + if changed: + self.changes = UsableChanges(params=changed) + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + if self.client.platform == 'Velos Partition': + raise F5ModuleError("Target device is a Velos Partition, aborting.") + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + + if state == "install": + changed = self.install_image() + elif state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def install_image(self): + if self.exists(): + return False + else: + return self.create() + + def present(self): + if self.exists(): + return False + + def absent(self): + return False + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.install_software_image() + return True + + def exists(self): + if self.install_status_complete(): + pass + # try: + # result = self.install_status_complete() + # return True + uri = "/openconfig-system:system/f5-system-image:image/state/install" + response = self.client.get(uri) + if response['code'] == 404 and self.client.platform == 'Velos Controller': + uri = "/openconfig-system:system/f5-system-controller-image:image" + response = self.client.get(uri) + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + for key in response['contents']['f5-system-controller-image:image']['state']['controllers']['controller']: + if key['install-status'] == "success" and key['os-version'] == self.want.image_version: + return True + return False + if response['code'] == 404 and self.client.platform == 'Velos Partition': + uri = "/openconfig-platform:components" + response = self.client.get(uri) + platform_data = response['contents']['openconfig-platform:components']['component'][0] + for key in platform_data['f5-platform:software']['state']['software-components']['software-component']: + if key['state']['version'] != self.want.image_version: + return False + return True + if response['code'] in [200, 201, 202] and self.client.platform == 'rSeries Platform': + if response['contents']['f5-system-image:install']['install-os-version'] == self.want.image_version and \ + response['contents']['f5-system-image:install']['install-status'] == 'success': + return True + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + # { + # "f5-system-image:install": { + # "install-os-version": "1.8.0-13819", + # "install-service-version": "1.8.0-13819", + # "install-status": "success" + # } + # } + return False + + def install_software_image(self): + params = self.changes.api_params() + if self.client.platform == 'rSeries Platform': + uri = "/openconfig-system:system/f5-system-image:image/f5-system-image:set-version" + payload = { + "f5-system-image:iso-version": params['image-name'], + "f5-system-image:proceed": "yes" + } + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(f"Failed to install system image with {response['contents']}") + # {'f5-system-image:output': {'response': 'System ISO version has been set.\\nEstimated time: 11 minutes\\nReboot(s): 1'}} + # raise F5ModuleError(f"code {response['code']} contents: {response['contents']}") + if 'f5-system-image:output' in response['contents'] and 'response' in response['contents']['f5-system-image:output']: + result = response['contents']['f5-system-image:output']['response'] + if result.startswith('System ISO version has been set'): + self.changes.update({"message": f"Image {self.want.image_version} install started."}) + return True + if result.startswith('File import with same local file name is in progress'): + raise F5ModuleError(f"Failed to import system image, error: {result}") + return True + if self.client.platform == 'Velos Controller': + uri = "/openconfig-system:system/f5-system-controller-image:image/f5-system-controller-image:set-version" + # openconfig-system:system/f5-system-controller-image:image/f5-system-controller-image:set-version + payload = { + "f5-system-controller-image:iso-version": params['image-name'], + "f5-system-controller-image:proceed": "yes" + } + response = self.client.post(uri, data=payload) + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(f"Failed to install system image with {response['contents']}") + if 'f5-system-controller-image:output' in response['contents'] and 'response' in response['contents']['f5-system-controller-image:output']: + result = response['contents']['f5-system-controller-image:output']['response'] + if result.startswith('System ISO version has been set'): + self.changes.update({"message": f"Image {self.want.image_version} install started."}) + return True + if result.startswith('File import with same local file name is in progress'): + raise F5ModuleError(f"Failed to import system image, error: {result}") + return True + if self.client.platform == 'Velos Partition': + raise F5ModuleError("Target device is a VELOS partition, aborting.") + + def install_status_complete(self): + delay, period = self.want.timeout + for x in range(0, period): + if self.is_still_installing(): + time.sleep(delay) + continue + if not self.changes.message: + self.changes.update({"message": f"Image {self.want.image_version} import successful."}) + return True + raise F5ModuleError( + "Module timeout reached, state change is unknown, " + "please increase the timeout parameter for long lived actions." + ) + + def is_still_installing(self): + try: + uri = "api" + response = self.client.get(uri, scope="/") + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + return False + except Exception as e: + if e.__class__.__name__ == 'ConnectionError': + return True + raise F5ModuleError(f"Failed to check the status of the api: {self.want.image_version} {e}") + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + image_version=dict(type='str', required=True), + timeout=dict( + type='int', + default=300 + ), + state=dict( + default='install', + choices=['install', 'present', 'absent'] + ), + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/plugins/modules/f5os_tls_cert_key.py b/ansible_collections/f5networks/f5os/plugins/modules/f5os_tls_cert_key.py new file mode 100644 index 0000000..7ff9590 --- /dev/null +++ b/ansible_collections/f5networks/f5os/plugins/modules/f5os_tls_cert_key.py @@ -0,0 +1,710 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: f5os_tls_cert_key +short_description: Manage TLS certificate and key on F5OS devices. +description: + - Manage TLS certificate and key on F5OS devices. +version_added: 1.11.0 +options: + name: + description: + - This specifies the common name of the certificate. + type: str + required: True + subject_alternative_name: + description: + - This specifies the subject alternative name of the certificate. + - This parameter is reuiqred for rSeries Platform. + type: str + email: + description: + - This specifies the email address of the certificate holder. + type: str + city: + description: + - This specifies the residing city of the certificate holder. + type: str + province: + description: + - This specifies the province or state of the certificate holder. + type: str + country: + description: + - This specifies the country of the certificate holder. + type: str + organization: + description: + - This specifies the organization of the certificate holder. + type: str + unit: + description: + - This specifies the organizational unit of the certificate holder. + type: str + version: + description: + - This specifies the version of the certificate. + type: int + days_valid: + description: + - This specifies the number of days the certificate is valid. + type: int + key_type: + description: + - This specifies the type of the key. + type: str + choices: + - rsa + - encrypted rsa + - ecdsa + - encrypted ecdsa + key_size: + description: + - This specifies the length of the key. + - This parameter is required when C(key_type) is C(rsa) or C(encrypted rsa). + type: int + choices: + - 2048 + - 3072 + - 4096 + key_curve: + description: + - This specifies the specific elliptic curve used in ECC. + - This parameter is required when C(key_type) is C(ecdsa) or C(encrypted ecdsa). + type: str + choices: + - prime256v1 + - secp384r1 + key_passphrase: + description: + - This specifies the passphrase for the key. + type: str + confirm_key_passphrase: + description: + - This specifies the confirmation of the passphrase for the key. + - The value should be the same as C(key_passphrase). + type: str + store_tls: + description: + - This specifies whether to store the certificate and key on the device. + type: bool + state: + description: + - The certificate state. If C(absent), deletes the certificate if it exists. + - If C(present), the certificate is created. + type: str + choices: + - present + - absent + default: present +author: + - Rohit Upadhyay (@rupadhyay) +''' + +EXAMPLES = r''' +- name: Create tls cert and key on velos + f5os_tls_cert_key: + name: "test_cert" + email: "name@company.com" + city: Telangana + province: Hyderabad + country: IN + organization: FZ + unit: IT + version: 1 + days_valid: 365 + key_type: "rsa" + key_size: 2048 + store_tls: true +''' + +RETURN = r''' +name: + description: The common name of the certificate. + returned: changed + type: str + sample: test_cert +subject_alternative_name: + description: The subject alternative name of the certificate. + returned: changed + type: str + sample: DNS:example.com +email: + description: The email address of the certificate holder. + returned: changed + type: str + sample: name@company.com +city: + description: The residing city of the certificate holder. + returned: changed + type: str + sample: Delhi +province: + description: The province or state of the certificate holder. + returned: changed + type: str + sample: Telangana +country: + description: The country of the certificate holder. + returned: changed + type: str + sample: IN +organization: + description: The organization of the certificate holder. + returned: changed + type: str + sample: FZ +unit: + description: The organizational unit of the certificate holder. + returned: changed + type: str + sample: IT +version: + description: The version of the certificate. + returned: changed + type: int + sample: 1 +days_valid: + description: The number of days the certificate is valid. + returned: changed + type: int + sample: 365 +key_type: + description: The type of the key. + returned: changed + type: str + sample: rsa +key_size: + description: The length of the key. + returned: changed + type: int + sample: 2048 +key_curve: + description: The specific elliptic curve used in ECC. + returned: changed + type: str + sample: prime256v1 +''' + +import datetime +import traceback + +try: + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.backends import default_backend +except ImportError: + CRYPTOGRAPHY_INSTALLED = False + PACKAGING_IMPORT_ERROR = traceback.format_exc() +else: + CRYPTOGRAPHY_INSTALLED = True + PACKAGING_IMPORT_ERROR = None + +from ansible.module_utils.basic import ( + AnsibleModule, missing_required_lib +) +from ansible.module_utils.connection import Connection + +from ..module_utils.client import ( + F5Client, send_teem +) +from ..module_utils.common import ( + F5ModuleError, AnsibleF5Parameters, +) + + +class Parameters(AnsibleF5Parameters): + api_map = { + "f5-openconfig-aaa-tls:name": "name", + "f5-openconfig-aaa-tls:san": "subject_alternative_name", + "f5-openconfig-aaa-tls:email": "email", + "f5-openconfig-aaa-tls:city": "city", + "f5-openconfig-aaa-tls:region": "province", + "f5-openconfig-aaa-tls:country": "country", + "f5-openconfig-aaa-tls:organization": "organization", + "f5-openconfig-aaa-tls:unit": "unit", + "f5-openconfig-aaa-tls:version": "version", + "f5-openconfig-aaa-tls:days-valid": "days_valid", + "f5-openconfig-aaa-tls:key-type": "key_type", + "f5-openconfig-aaa-tls:key-size": "key_size", + "f5-openconfig-aaa-tls:curve-name": "key_curve", + "f5-openconfig-aaa-tls:key-passphrase": "key_passphrase", + "f5-openconfig-aaa-tls:confirm-key-passphrase": "confirm_key_passphrase", + "f5-openconfig-aaa-tls:store-tls": "store_tls", + } + + api_attributes = [ + "f5-openconfig-aaa-tls:name", + "f5-openconfig-aaa-tls:san", + "f5-openconfig-aaa-tls:email", + "f5-openconfig-aaa-tls:city", + "f5-openconfig-aaa-tls:region", + "f5-openconfig-aaa-tls:country", + "f5-openconfig-aaa-tls:organization", + "f5-openconfig-aaa-tls:unit", + "f5-openconfig-aaa-tls:version", + "f5-openconfig-aaa-tls:days-valid", + "f5-openconfig-aaa-tls:key-type", + "f5-openconfig-aaa-tls:key-size", + "f5-openconfig-aaa-tls:curve-name", + "f5-openconfig-aaa-tls:key-passphrase", + "f5-openconfig-aaa-tls:confirm-key-passphrase", + "f5-openconfig-aaa-tls:store-tls", + ] + + returnables = [ + "name", + "subject_alternative_name", + "email", + "city", + "province", + "country", + "organization", + "unit", + "version", + "days_valid", + "key_type", + "key_size", + "key_curve", + "key_passphrase", + "confirm_key_passphrase", + "store_tls", + ] + + updatables = [ + "name", + "email", + "city", + "province", + "country", + "organization", + "unit", + "days_valid", + ] + + +class ApiParameters(Parameters): + @property + def name(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + + @property + def email(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.EMAIL_ADDRESS)[0].value + + @property + def city(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value + + @property + def province(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value + + @property + def country(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value + + @property + def organization(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value + + @property + def unit(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value + + @property + def version(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.version.value + + @property + def valid_from(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.not_valid_before_utc + + @property + def valid_until(self): + if "cert" not in self._values: + return None + cert = self._values["cert"] + return cert.not_valid_after_utc + + @property + def days_valid(self): + if "cert" not in self._values: + return None + return (self.valid_until - self.valid_from).days + + @property + def password(self): + return None + + +class ModuleParameters(Parameters): + @property + def subject_alternative_name(self): + rseries = self.client.platform == "rSeries Platform" + if not self._values["subject_alternative_name"] and rseries: + raise F5ModuleError( + "The 'subject_alternative_name' parameter is required for rSeries Platform." + ) + + return self._values["subject_alternative_name"] + + +class Changes(Parameters): + def to_return(self): + result = {} + try: + for returnable in self.returnables: + result[returnable] = getattr(self, returnable) + result = self._filter_params(result) + except Exception: # pragma: no cover + raise + return result + + +class UsableChanges(Changes): + pass + + +class ReportableChanges(Changes): + pass + + +class Difference(object): + def __init__(self, want, have=None): + self.want = want + self.have = have + + def compare(self, param): + try: + result = getattr(self, param) + return result # pragma: no cover + except AttributeError: + return self.__default(param) + + def __default(self, param): + attr1 = getattr(self.want, param) + try: + attr2 = getattr(self.have, param) + if attr1 != attr2: + return attr1 + except AttributeError: # pragma: no cover + return attr1 + + +class ModuleManager(object): + def __init__(self, *args, **kwargs): + self.module = kwargs.get('module', None) + self.connection = kwargs.get('connection', None) + self.client = F5Client(module=self.module, client=self.connection) + self.want = ModuleParameters(params=self.module.params) + self.changes = UsableChanges() + self.have = ApiParameters() + + def _set_changed_options(self): + changed = {} + for key in Parameters.returnables: + if getattr(self.want, key) is not None: + changed[key] = getattr(self.want, key) + + if changed: + self.changes = UsableChanges(params=changed) + + def _update_changed_options(self): + diff = Difference(self.want, self.have) + updatables = Parameters.updatables + changed = dict() + for k in updatables: + change = diff.compare(k) + if change is None: + continue + else: + if isinstance(change, dict): + changed.update(change) # pragma: no cover + else: + changed[k] = change + + if changed: + self.changes = UsableChanges(params=changed) + return True + return False + + def _announce_deprecations(self, result): # pragma: no cover + warnings = result.pop('__warnings', []) + for warning in warnings: + self.client.module.deprecate( + msg=warning['msg'], + version=warning['version'] + ) + + def exec_module(self): + start = datetime.datetime.now().isoformat() + changed = False + result = dict() + state = self.want.state + + if state == "present": + changed = self.present() + elif state == "absent": + changed = self.absent() + + reportable = ReportableChanges(params=self.changes.to_return()) + changes = reportable.to_return() + result.update(**changes) + result.update(dict(changed=changed)) + self._announce_deprecations(result) + send_teem(self.client, start) + return result + + def present(self): + if self.exists(): + return self.update() + else: + return self.create() + + def absent(self): + if self.exists(): + return self.remove() + return False + + def should_update(self): + result = self._update_changed_options() + if result: + return True + return False + + def update(self): + self.have = self.read_current_from_device() + if not self.should_update(): + return False + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def remove(self): + if self.module.check_mode: # pragma: no cover + return True + self.remove_from_device() + if self.exists(): + raise F5ModuleError("Failed to delete the resource.") + return True + + def create(self): + self._set_changed_options() + if self.module.check_mode: # pragma: no cover + return True + self.create_on_device() + return True + + def exists(self): + uri = "/openconfig-system:system/aaa/f5-openconfig-aaa-tls:tls" + response = self.client.get(uri) + + if response['code'] == 404: + return False + + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + + config = response['contents']['f5-openconfig-aaa-tls:tls']['config'] + if 'certificate' not in config or 'key' not in config: + return False + + return True + + def create_on_device(self): + params = self.changes.api_params() + params["f5-openconfig-aaa-tls:name"] = self.want.name + params["f5-openconfig-aaa-tls:store-tls"] = True + + if self.client.platform == "rSeries Platform": + if self.want.subject_alternative_name is None: + raise F5ModuleError( + "The 'subject_alternative_name' parameter is required for rSeries Platform." + ) + params["f5-openconfig-aaa-tls:san"] = self.want.subject_alternative_name + + params = self.add_defaults(params) + + uri = "/openconfig-system:system/aaa/f5-openconfig-aaa-tls:tls/f5-openconfig-aaa-tls:create-self-signed-cert" + + response = self.client.post(uri, data=params) + + if response['code'] not in [200, 201, 202, 204]: + raise F5ModuleError(response['contents']) + + return True + + def add_defaults(self, params): + if "f5-openconfig-aaa-tls:email" not in params: + params["f5-openconfig-aaa-tls:email"] = self.want.email if self.want.email else self.have.email + if "f5-openconfig-aaa-tls:city" not in params: + params["f5-openconfig-aaa-tls:city"] = self.want.city if self.want.city else self.have.city + if "f5-openconfig-aaa-tls:region" not in params: + params["f5-openconfig-aaa-tls:region"] = self.want.province if self.want.province else self.have.province + if "f5-openconfig-aaa-tls:country" not in params: + params["f5-openconfig-aaa-tls:country"] = self.want.country if self.want.country else self.have.country + if "f5-openconfig-aaa-tls:organization" not in params: + params["f5-openconfig-aaa-tls:organization"] = self.want.organization if self.want.organization else self.have.organization + if "f5-openconfig-aaa-tls:unit" not in params: + params["f5-openconfig-aaa-tls:unit"] = self.want.unit if self.want.unit else self.have.unit + if "f5-openconfig-aaa-tls:version" not in params: + params["f5-openconfig-aaa-tls:version"] = self.want.version if self.want.version else self.have.version + if "f5-openconfig-aaa-tls:days-valid" not in params: + params["f5-openconfig-aaa-tls:days-valid"] = self.want.days_valid if self.want.days_valid else self.have.days_valid + if "f5-openconfig-aaa-tls:key-type" not in params: + params["f5-openconfig-aaa-tls:key-type"] = self.want.key_type if self.want.key_type else self.have.key_type + + if "f5-openconfig-aaa-tls:key-size" not in params and params["f5-openconfig-aaa-tls:key-type"] in ["encrypted rsa", "rsa"]: + params["f5-openconfig-aaa-tls:key-size"] = self.want.key_size if self.want.key_size else self.have.key_size + if "f5-openconfig-aaa-tls:curve-name" not in params and params["f5-openconfig-aaa-tls:key-type"] in ["encrypted ecdsa", "ecdsa"]: + params["f5-openconfig-aaa-tls:curve-name"] = self.want.key_curve if self.want.key_curve else self.have.key_curve + + return params + + def remove_from_device(self): + uri = "/openconfig-system:system/aaa/f5-openconfig-aaa-tls:tls/config" + params = { + "f5-openconfig-aaa-tls:config": { + "verify-client": False, + "verify-client-depth": 1 + } + } + + response = self.client.put(uri, params) + if response['code'] in [200, 201, 202, 204]: + return True + raise F5ModuleError(response['contents']) + + def read_current_from_device(self): + uri = "/openconfig-system:system/aaa/f5-openconfig-aaa-tls:tls" + response = self.client.get(uri) + + if response['code'] not in [200, 201, 202]: + raise F5ModuleError(response['contents']) + + cert_string = response['contents']['f5-openconfig-aaa-tls:tls']['config']['certificate'] + cert = x509.load_pem_x509_certificate(cert_string.encode("utf-8"), default_backend()) + + return ApiParameters(params={"cert": cert}) + + +class ArgumentSpec(object): + def __init__(self): + self.supports_check_mode = True + argument_spec = dict( + name=dict(required=True), + subject_alternative_name=dict(), + email=dict(), + city=dict(), + province=dict(), + country=dict(), + organization=dict(), + unit=dict(), + version=dict(type="int"), + days_valid=dict(type="int"), + key_type=dict( + choices=[ + "rsa", + "encrypted rsa", + "ecdsa", + "encrypted ecdsa", + ] + ), + key_size=dict( + type='int', + choices=[2048, 3072, 4096] + ), + key_curve=dict( + choices=["prime256v1", "secp384r1"] + ), + key_passphrase=dict( + no_log=True + ), + confirm_key_passphrase=dict( + no_log=True + ), + store_tls=dict( + type="bool" + ), + state=dict( + default="present", + choices=["present", "absent"] + ) + ) + self.argument_spec = {} + self.argument_spec.update(argument_spec) + + self.required_if = [ + ("key_type", "rsa", ["key_size"]), + ("key_type", "ecdsa", ["key_curve"]), + ("key_type", "encrypted rsa", ["key_size", "key_passphrase", "confirm_key_passphrase"]), + ("key_type", "encrypted ecdsa", ["key_curve", "key_passphrase", "confirm_key_passphrase"]), + ] + + self.mutually_exclusive = [ + ["key_size", "key_curve"], + ] + + +def main(): + spec = ArgumentSpec() + + module = AnsibleModule( + argument_spec=spec.argument_spec, + supports_check_mode=spec.supports_check_mode, + ) + + if not CRYPTOGRAPHY_INSTALLED: + module.fail_json( + msg=missing_required_lib("cryptography"), + exception=PACKAGING_IMPORT_ERROR + ) + + try: + mm = ModuleManager(module=module, connection=Connection(module._socket_path)) + results = mm.exec_module() + module.exit_json(**results) + except F5ModuleError as ex: + module.fail_json(msg=str(ex)) + + +if __name__ == '__main__': # pragma: no cover + main() diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_get_tls_cert.json b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_get_tls_cert.json new file mode 100644 index 0000000..021db7f --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_get_tls_cert.json @@ -0,0 +1,14 @@ +{ + "f5-openconfig-aaa-tls:tls": { + "config": { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDizCCAnOgAwIBAgIJAKwhTtvf+0lbMA0GCSqGSIb3DQEBCwUAMHUxEjAQBgNV\nBAMMCXRlc3RfY2VydDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAk5ZMQ4wDAYDVQQH\nDAVWZWdhczELMAkGA1UECgwCRloxCzAJBgNVBAsMAklUMRswGQYJKoZIhvcNAQkB\nFgxuYW1lQG9yZy5jb20wHhcNMjQwOTA1MDYyOTAyWhcNMjUwOTA1MDYyOTAyWjB1\nMRIwEAYDVQQDDAl0ZXN0X2NlcnQxCzAJBgNVBAYTAklOMQswCQYDVQQIDAJOWTEO\nMAwGA1UEBwwFVmVnYXMxCzAJBgNVBAoMAkZaMQswCQYDVQQLDAJJVDEbMBkGCSqG\nSIb3DQEJARYMbmFtZUBvcmcuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAu71qCP+onPBceMLrqGe+ZybZM9XmhGl/f8I0DyO4tuLKEM4bDyp30Azn\nAE0kmW3vyVGAmFTyMoDHD9czGmaC9mJ3Hx9t2tyZte9ehX/tLoHoY9rEAGnP1Cgj\ntnS+jo+D5orqfTrJftDrCweVQb4iEy3OTjkry1g84EiR1vJhmW2kPULG79dmiJh5\n0Pr5/8ohOid+/F0s/UQqQw4j5Ex8l2ZMVgyCzcJ2A8+0fI+Reh+16Esc+cF1LcSP\nlv9XpjhiUhgH2+VeWgNWYNyZoOopk/664f8vfCtWv8FQAGqyxctOVK6srW0XGP5U\nFw8UVjIPalQqAFXy8wc1xQn4Of7qpwIDAQABox4wHDAaBgNVHREEEzARgg93d3cu\nZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADggEBADGZWGo7//XqSs38tzg95BTo\n2iXIkvBphsPhJ5Ww2NNPO/vPafsEiKkXw9NvWg4mbHrWfS72HUyWkbLmBVwSQ3PX\nnMLZmTj6sCvEbXQ1/JrTt0TwpwpmHaKe8ek3iOvRoJRo+3QJHG5xbKdW3heykg38\nNkLCWjTtyokj9DNtmtRYChbuUXls4kRHQqwRSCebGwVQfOpgIzPPBswaF+wYmRQb\nuTwir1vEZW+mBfpecGwGJl2mp4cP1VUbJqIIpk7BjKiw7anjmh/v4IOnmBEeHEv+\nCV0Gn1Oiyu4har/xcm3VbXv7Q5ZHjVXmeg5CP8M3esNcd5SwMJKeMR4MTCOEJoY=\n-----END CERTIFICATE-----", + "key": "$8$/7FanxvUlOpwXreYh/EWskdy1zCReEUZJvH8v5tlhAWX2wY3mwCPDYC3VYDxjTc+kf9q00k3\nNJU09z3Co1Q2FwU2lOxMak4Tt6GEe4RSzMy7k++8Ou0lhn7AAc4NCAK5SA6xQr0VQtVhcp/B\nD7MfUYuLPL3CCycfRqn+tf6O1nSLEcC45OetN4MFkBfFoJrxEUmOXxzf70nenif1ap+o+k8C\nGl6TLQY/WZfbBDRp9DVhICARlkkMTLqYYu1DfJ56u6X5Qo7i2C57QzklOlCVG0MyfPi3zcnz\nTZOmCRkfRjUa1jdvrOLIpUPnKy36dLKzI/xqrewfyEGuE1/bpVm1wNR+JQZ3ebR79AMrxM4a\ncZZdZFBSg1MkaZfbCKsihctIiVjU0VpF8Ngt9l+sszSFUP1X+Tdn80M8CQJw47bz5H7SOfe9\nXGNnwggLS5iteuS3X3Lqdoc10lnU8IKLb4iNp77KVceLlGEZ2lcChHQ/rtWO4xznlN9n5azG\nflhXLW0X3EnnfMI87BnG9EBorUPJqbsT4E8tV4tq1H0m+PN/a/SbbTnvnd+UT9LDe5rLNypF\nggrQYbro2a4PtPWNvmeA0DG86pM6aB3Lm5bEHXmEwlJvubCWvuNOu0BV33gyg+sXmR2PUmew\nP/rdwS1CQxMytV48dwpJTc/NB2FfYllTVk7o6NeLYUiG0ZKFOEKMINijfc/+XonUMJW1YaU0\nh3FWIHpj0s91svNOTaCS2vSx7LmQr6D4tcjOzdKVmAEbA2Vs5m7/Kok2BaHcePFHNKaogzCH\nWtBiCwfHhnrrAt4lp7VdRpTuHjx98n0xr6hRJNYRAeqO7pAnR97qkjzy0p0jeXeY/yMnsmB6\n9XSsjdgx+ha7Fkr76+UEdILXzeTpavj7xbd7BKdEc86n1Ip7EsNPeZYtiH/PmbPN7N5r98R7\noIJahMMQGOuzFpjOJhb7snDMZ4hA+g9/3CesVkQpILYQyqNX8EQhNqno+dZd5McRCwhMssXg\n8madhsYyku3m/MB6MRNyi8ESqa3wQ/A+DsfrHlVduqg/v4CDRvi4Eprpkj/oJxTLtPfyubNp\nQsrHKhG0eFCiSZj4/F+wVIBISWP4/PwYJlgpswq1Bi0eXvbPG5+YdC9eQ9CF/1q01pfID9sI\njNaoQEimcZTrI0PPOyHpKImbs2Q0Y2Z7bb27TcpOehs87h5Ki6B2RIZwR1kFaNqjY3RS3fWK\nkLhKfH2CnbUO1clFjyjwvrC9OvFuAqyDDhmPvFugsH74yTZIc0Z5yLrNDhE3IFwR8n0FWxBP\n5DBw36YvZiwT7L5iDIaiy515tCDJq4bAeSIT1LdK51LmqOrB+bBreW2yfSAixw3SMOilUEqk\nenyazaPw1uV0vXCoFcN+/xXFjQzwsoUwqt62v3OeUV1aTxXrZjpPkfXteQf9m0324S8dSyhi\nejG3IppNEyJ0abxg7zFol5Gigj2+fs/DjHbeg/XOMV8V7+/Yu0s1Zox/6EtrBH/FFxYLHQko\nfizD6IDtnLEodiVBv4MkNGn56JQaBiKtaQmmgtdyLzVUvDXA+b3RF6Yj0a+3gXDWrpmRRPu6\niTYYUzdlO+7SaTH7d7/Dvwakp8gzq45lPO+emYAdlaJEfjtpY4x+q1XDeTBez0qDONSqpbCQ\nCpvTYWurRHHIdPqydpA8CkfRkxc/Qi3ZpL9byXgjtIwqrK4WjVgVm33u/xWNn/ZOWcXUBXT/\n0Dft98zQQQMOG1w159AQcydiV1FrPL8CxdmXPKUR8fDBk7QEQzcwhoT6q8QKFZsi55ST9QEo\nv/McP8O37ZQnJmPfqI0NkdNfg57DkX8z9AWnq4XFqv1ijt7ezny5cykjxCRmoe+lpI0u2LPv\nd5+ht7IJEkK0ynowtVusecyaCZFMsHyMFqnvuhWUmhcAWbRov7Xf2vhZXPS4w8vigL1HyHyJ\nE6DUXm1h/7sk5tBd3NitYZQnC0wxXIGpldnWWWcu25J3zCXPlkFFXvi3nM/QqrS0xES4Jj5p\nSG8tpXDxBSgdqPvfEb2wksMr/lYgJGgU+aCdstVIzYmQiATdbuzDIDUCltwMiG5nooVu/GFb\nn/SPHeOenLv6kJXKqOt1rfN+xb5q7EJxgeTSOqx/tFO4g0aj7mQhpK2hPvXFc1El6mUIMj7M\niH/aTBPuui/6QricB3m3izSDgV2FP5Xl0si+v9OZteujTbzgCto4uCLKHFrwcg29Abl1toRN\nXOa+Ppoi1oSUK2QqwR6UE8U88WAbNA==", + "verify-client": false, + "verify-client-depth": 1 + }, + "state": { + "verify-client": false, + "verify-client-depth": 1 + } + } +} \ No newline at end of file diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_community_user_target.json b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_community_user_target.json new file mode 100644 index 0000000..8374abe --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_community_user_target.json @@ -0,0 +1,40 @@ +{ + "code": 200, + "contents": { + "f5-system-snmp:snmp": { + "communities": { + "community": [ + { + "config": { + "name": "test1_com", + "security-model": ["v1", "v2", "v3"] + } + } + ] + }, + "users": { + "user": [{ + "config": { + "name": "test2_user" + } + }] + }, + "targets": { + "target": [{ + "config": { + "name": "target1", + "security-model": "v2", + "ipv4": { + "address": "8.9.7.5", + "port": 443 + }, + "ipv6": { + "address": "2001:db8::1", + "port": 443 + } + } + }] + } + } + } +} \ No newline at end of file diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_mib.json b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_mib.json new file mode 100644 index 0000000..d7881f9 --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/fixtures/f5os_snmp_mib.json @@ -0,0 +1,6 @@ +{ + "code": 200, + "contents" : { + "SNMPv2-MIB:system": {} + } +} \ No newline at end of file diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_snmp.py b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_snmp.py new file mode 100644 index 0000000..96e1102 --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_snmp.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.f5networks.f5os.plugins.modules import f5os_snmp +from ansible_collections.f5networks.f5os.plugins.modules.f5os_snmp import ( + ArgumentSpec, ModuleManager +) + +from ansible_collections.f5networks.f5os.plugins.module_utils.common import F5ModuleError + +from ansible_collections.f5networks.f5os.tests.compat import unittest +from ansible_collections.f5networks.f5os.tests.compat.mock import Mock, patch +from ansible_collections.f5networks.f5os.tests.modules.utils import ( + set_module_args, exit_json, fail_json, AnsibleFailJson, AnsibleExitJson +) + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestManager(unittest.TestCase): + def setUp(self): + self.spec = ArgumentSpec() + self.mock_module_helper = patch.multiple(AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module_helper.start() + self.p1 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_snmp.F5Client') + self.m1 = self.p1.start() + self.m1.return_value = Mock() + self.p2 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_snmp.send_teem') + self.m2 = self.p2.start() + self.m2.return_value = True + + def tearDown(self): + self.p1.stop() + self.p2.stop() + self.mock_module_helper.stop() + + def test_create_snmp_community(self, *args): + set_module_args(dict( + snmp_community=[dict( + name='test1_com', + security_model=['v1', 'v2'], + )], + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.client.get = Mock(return_value={'code': 404}) + mm.client.post = Mock(return_value={'code': 201}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 1) + + def test_create_snmp_user(self, *args): + set_module_args(dict( + snmp_user=[dict( + name='user1', + auth_proto="MD5", + auth_passwd="pass1", + privacy_proto="DES", + privacy_passwd="pass2", + )], + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.client.get = Mock(return_value={'code': 404}) + mm.client.post = Mock(return_value={'code': 201}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 1) + + def test_create_snmp_target(self, *args): + set_module_args(dict( + snmp_target=[dict( + name='target1', + security_model="v1", + community="community1", + ipv4_address="1.2.3.4", + ipv6_address="2001:0000:130F:0000:0000:09C0:876A:130B", + port="8080", + user="user1", + )], + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.client.get = Mock(return_value={'code': 404}) + mm.client.post = Mock(return_value={'code': 201}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 1) + + def test_create_snmp_mib(self, *args): + set_module_args(dict( + snmp_mib=dict( + syscontact='user user@email.com', + sysname='appliance-x', + syslocation="appliance-x.chassis.local", + ), + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.exists = Mock(return_value=False) + mm.client.post = Mock(return_value={'code': 201}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + + def test_update_snmp_community(self, *args): + set_module_args(dict( + snmp_community=[dict( + name='test1_com', + security_model=['v1', 'v2'], + )], + )) + + existing_data = load_fixture("f5os_snmp_community_user_target.json") + existing_data_mib = load_fixture("f5os_snmp_mib.json") + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.exists = Mock(return_value=True) + mm.client.get = Mock(side_effect=[existing_data, existing_data_mib]) + mm.client.put = Mock(return_value={'code': 200}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.put.call_count, 1) + self.assertEqual(mm.client.get.call_count, 2) + + def test_update_snmp_target(self, *args): + set_module_args(dict( + snmp_target=[dict( + name='target1', + security_model="v1", + community="community1", + ipv4_address="1.2.3.4", + ipv6_address="2001:0000:130F:0000:0000:09C0:876A:130B", + port="8080", + user="user1", + )], + )) + + existing_data = load_fixture("f5os_snmp_community_user_target.json") + existing_data_mib = load_fixture("f5os_snmp_mib.json") + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + mm.exists = Mock(return_value=True) + mm.client.get = Mock(side_effect=[existing_data, existing_data_mib]) + mm.client.put = Mock(return_value={'code': 200}) + + results = mm.exec_module() + + self.assertTrue(results['changed']) + self.assertEqual(mm.client.put.call_count, 1) + self.assertEqual(mm.client.get.call_count, 2) + + @patch.object(f5os_snmp, 'Connection') + @patch.object(f5os_snmp.ModuleManager, 'exec_module', Mock(return_value={'changed': False})) + def test_main_function_success(self, *args): + set_module_args(dict( + snmp_community=[dict( + name='test1_com', + security_model=['v1', 'v2'], + )], + )) + + with self.assertRaises(AnsibleExitJson) as result: + f5os_snmp.main() + + self.assertFalse(result.exception.args[0]['changed']) + + @patch.object(f5os_snmp, 'Connection') + @patch.object(f5os_snmp.ModuleManager, 'exec_module', + Mock(side_effect=F5ModuleError('This module has failed.')) + ) + def test_main_function_failed(self, *args): + set_module_args(dict( + snmp_community=[dict( + name='test1_com', + security_model=['v1', 'v2'], + )], + )) + + with self.assertRaises(AnsibleFailJson) as result: + f5os_snmp.main() + + self.assertTrue(result.exception.args[0]['failed']) + self.assertIn('This module has failed', result.exception.args[0]['msg']) diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_import.py b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_import.py new file mode 100644 index 0000000..bab86a1 --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_import.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os + +from ansible.module_utils.basic import AnsibleModule + +# from ansible_collections.f5networks.f5os.plugins.modules import f5os_system_image_import +from ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_import import ( + ArgumentSpec, ModuleManager +) + +# from ansible_collections.f5networks.f5os.plugins.module_utils.common import F5ModuleError + +from ansible_collections.f5networks.f5os.tests.compat import unittest +from ansible_collections.f5networks.f5os.tests.compat.mock import Mock, patch +from ansible_collections.f5networks.f5os.tests.modules.utils import ( + set_module_args, exit_json, fail_json + # AnsibleFailJson, AnsibleExitJson +) + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestManager(unittest.TestCase): + def setUp(self): + self.spec = ArgumentSpec() + self.mock_module_helper = patch.multiple(AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module_helper.start() + self.p1 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_import.F5Client') + self.m1 = self.p1.start() + self.m1.return_value = Mock() + self.p2 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_import.send_teem') + self.m2 = self.p2.start() + self.m2.return_value = True + + def tearDown(self): + self.p1.stop() + self.p2.stop() + self.mock_module_helper.stop() + + def test_system_image_exist_import(self, *args): + set_module_args(dict( + remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + local_path="images/staging", + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data = {"f5-utils-file-transfer:output": {"entries": [{"name": "F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso", "date": "string", "size": "string"}]}} + mm.client.post = Mock(return_value={'code': 201, 'contents': get_data}) + + results = mm.exec_module() + self.assertFalse(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + + def test_system_image_import(self, *args): + set_module_args(dict( + remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + local_path="images/staging", + )) + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data = {"f5-utils-file-transfer:output": {"entries": [{"name": "F5OS-A-1.8.0-14136.R5R10.CANDIDATE.iso", "date": "string", "size": "string"}]}} + mm.client.post = Mock(return_value={'code': 201, 'contents': get_data}) + + results = mm.exec_module() + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 2) + # self.assertEqual(mm.client.get.call_count, 1) + + def test_system_image_import_status(self, *args): + set_module_args(dict( + remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + local_path="images/staging", + state='present', + operation_id='Import_12345', + timeout=300, + )) + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data1 = { + "f5-utils-file-transfer:transfer-operation": [ + {"operation-id": "Import_12345", + "operation": "Import", "protocol": "string", "local-file-path": "string", + "remote-host": "string", "remote-file-path": "string", "status": "Completed", "timestamp": "string"}] + } + get_data2 = {"f5-system-image:iso": [ + { + "version-iso": "1.8.0-14139", + "status": "ready", + "date": "2023-12-19", + "size": "3.52GB", + "type": "" + }, + { + "version-iso": "1.5.1-12283", + "status": "ready", + "date": "2023-08-14", + "size": "4.61GB", + "type": "" + }, + { + "version-iso": "1.5.0-5781", + "status": "ready", + "date": "2023-04-30", + "size": "3.32GB", + "type": "" + }] + } + mm.client.get = Mock(side_effect=[ + {'code': 201, 'contents': get_data1}, + {'code': 201, 'contents': get_data2}, + ]) + results = mm.exec_module() + self.assertTrue(results['changed']) + self.assertEqual(mm.client.get.call_count, 2) + + def test_system_image_import_remove(self, *args): + set_module_args(dict( + remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + state="absent", + )) + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data1 = {"f5-utils-file-transfer:output": {"entries": [{"name": "F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso", "date": "string", "size": "string"}]}} + get_data2 = {"f5-system-image:output": {"response": "Success"}} + mm.client.post = Mock(side_effect=[ + {'code': 201, 'contents': get_data1}, + {'code': 201, 'contents': get_data2}]) + for _var in range(2): # Attempt to call it three times + try: + results = mm.exec_module() + self.assertTrue(results['changed']) + self.assertEqual(mm.client.post.call_count, 2) + except StopIteration: + pass diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_install.py b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_install.py new file mode 100644 index 0000000..9d563b0 --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_system_image_install.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_install import ( + ArgumentSpec, ModuleManager +) + +from ansible_collections.f5networks.f5os.tests.compat import unittest +from ansible_collections.f5networks.f5os.tests.compat.mock import Mock, patch +from ansible_collections.f5networks.f5os.tests.modules.utils import ( + set_module_args, exit_json, fail_json + # AnsibleFailJson, AnsibleExitJson +) + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestManager(unittest.TestCase): + def setUp(self): + self.spec = ArgumentSpec() + self.mock_module_helper = patch.multiple(AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module_helper.start() + self.p1 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_install.F5Client') + self.m1 = self.p1.start() + self.m1.return_value = Mock() + self.p2 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_system_image_install.send_teem') + self.m2 = self.p2.start() + self.m2.return_value = True + + def tearDown(self): + self.p1.stop() + self.p2.stop() + self.mock_module_helper.stop() + + def test_system_image_version_exist(self, *args): + set_module_args(dict( + image_version='1.8.0-14139', + state='install', + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data = { + "f5-system-image:install": { + "install-os-version": "1.8.0-14139", + "install-service-version": "1.8.0-14139", + "install-status": "success" + } + } + # {"f5-utils-file-transfer:output": {"entries": [{"name": "F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso", "date": "string", "size": "string"}]}} + mm.client.get = Mock(return_value={'code': 201, 'contents': get_data}) + results = mm.exec_module() + self.assertFalse(results['changed']) + self.assertEqual(mm.client.get.call_count, 2) + + def test_system_image_install(self, *args): + set_module_args(dict( + image_version='1.8.0-14139', + state='install', + )) + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + ) + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + get_data = {'f5-system-image:output': {'response': 'System ISO version has been set.\\nEstimated time: 11 minutes\\nReboot(s): 1'}} + get_data2 = { + "f5-system-image:install": { + "install-os-version": "1.8.0-14138", + "install-service-version": "1.8.0-14138", + "install-status": "success" + } + } + mm.client.post = Mock(return_value={'code': 201, 'contents': get_data}) + mm.client.get = Mock(side_effect=[{'code': 200, 'contents': get_data2}, {'code': 201, 'contents': get_data2}]) + results = mm.exec_module() + self.assertTrue(results['changed']) + # self.assertFalse(results['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 2) + + # def test_system_image_import_status(self, *args): + # set_module_args(dict( + # remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + # local_path="images/staging", + # state='present', + # operation_id='Import_12345', + # timeout=300, + # )) + # module = AnsibleModule( + # argument_spec=self.spec.argument_spec, + # supports_check_mode=self.spec.supports_check_mode, + # ) + # mm = ModuleManager(module=module) + # mm.client.platform = 'rSeries Platform' + # get_data1 = { + # "f5-utils-file-transfer:transfer-operation": [ + # {"operation-id": "Import_12345", + # "operation": "Import", "protocol": "string", "local-file-path": "string", + # "remote-host": "string", "remote-file-path": "string", "status": "Completed", "timestamp": "string"}] + # } + # get_data2 = {"f5-system-image:iso": [ + # { + # "version-iso": "1.8.0-14139", + # "status": "ready", + # "date": "2023-12-19", + # "size": "3.52GB", + # "type": "" + # }, + # { + # "version-iso": "1.5.1-12283", + # "status": "ready", + # "date": "2023-08-14", + # "size": "4.61GB", + # "type": "" + # }, + # { + # "version-iso": "1.5.0-5781", + # "status": "ready", + # "date": "2023-04-30", + # "size": "3.32GB", + # "type": "" + # }] + # } + # mm.client.get = Mock(side_effect=[ + # {'code': 201, 'contents': get_data1}, + # {'code': 201, 'contents': get_data2}, + # ]) + # results = mm.exec_module() + # self.assertTrue(results['changed']) + # self.assertEqual(mm.client.get.call_count, 2) + + # def test_system_image_import_remove(self, *args): + # set_module_args(dict( + # remote_image_url='https://foo.bar.baz.net/foo/bar/F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso', + # state="absent", + # )) + # module = AnsibleModule( + # argument_spec=self.spec.argument_spec, + # supports_check_mode=self.spec.supports_check_mode, + # ) + # mm = ModuleManager(module=module) + # mm.client.platform = 'rSeries Platform' + # get_data1 = {"f5-utils-file-transfer:output": {"entries": [{"name": "F5OS-A-1.8.0-14139.R5R10.CANDIDATE.iso", "date": "string", "size": "string"}]}} + # get_data2 = {"f5-system-image:output": {"response": "Success"}} + # mm.client.post = Mock(side_effect=[ + # {'code': 201, 'contents': get_data1}, + # {'code': 201, 'contents': get_data2}]) + # for _var in range(2): # Attempt to call it three times + # try: + # results = mm.exec_module() + # self.assertTrue(results['changed']) + # self.assertEqual(mm.client.post.call_count, 2) + # except StopIteration: + # pass diff --git a/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_tls_cert_key.py b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_tls_cert_key.py new file mode 100644 index 0000000..e6b2e71 --- /dev/null +++ b/ansible_collections/f5networks/f5os/tests/modules/network/f5/test_f5os_tls_cert_key.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2022, F5 Networks Inc. +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import os + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.f5networks.f5os.plugins.modules import f5os_tls_cert_key +from ansible_collections.f5networks.f5os.plugins.modules.f5os_tls_cert_key import ( + ModuleParameters, ApiParameters, ArgumentSpec, ModuleManager, F5ModuleError +) + +from ansible_collections.f5networks.f5os.tests.compat import unittest +from ansible_collections.f5networks.f5os.tests.compat.mock import Mock, patch +from ansible_collections.f5networks.f5os.tests.modules.utils import ( + set_module_args, exit_json, fail_json, AnsibleFailJson, AnsibleExitJson +) + + +fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures') +fixture_data = {} + + +def load_fixture(name): + path = os.path.join(fixture_path, name) + + if path in fixture_data: + return fixture_data[path] + + with open(path) as f: + data = f.read() + + try: + data = json.loads(data) + except Exception: + pass + + fixture_data[path] = data + return data + + +class TestParameters(unittest.TestCase): + def setUp(self) -> None: + self.p1 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_tls_cert_key.F5Client') + self.m1 = self.p1.start() + self.m1.return_value = Mock() + + def tearDown(self) -> None: + self.p1.stop() + + def test_module_parameters(self): + pass + + def test_api_parameters(self): + args = dict() + p = ApiParameters(args) + + self.assertIsNone(p.name) + self.assertIsNone(p.email) + self.assertIsNone(p.city) + self.assertIsNone(p.province) + self.assertIsNone(p.country) + self.assertIsNone(p.organization) + self.assertIsNone(p.unit) + self.assertIsNone(p.version) + self.assertIsNone(p.days_valid) + self.assertIsNone(p.valid_from) + self.assertIsNone(p.valid_until) + self.assertIsNone(p.password) + + def test_module_parameters(self): + args1 = dict( + subject_alternative_name="DNS:www.example.com", + ) + p1 = ModuleParameters(params=args1) + + self.assertEqual(p1.subject_alternative_name, "DNS:www.example.com") + + +class TestManager(unittest.TestCase): + def setUp(self) -> None: + self.spec = ArgumentSpec() + self.mock_module_helper = patch.multiple(AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + self.mock_module_helper.start() + self.p1 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_tls_cert_key.F5Client') + self.m1 = self.p1.start() + self.m1.return_value = Mock() + self.p2 = patch('ansible_collections.f5networks.f5os.plugins.modules.f5os_tls_cert_key.send_teem') + self.m2 = self.p2.start() + self.m2.return_value = True + + def tearDown(self) -> None: + self.mock_module_helper.stop() + self.p1.stop() + self.p2.stop() + + def test_create_cert_key_on_velos(self, *args): + set_module_args(dict( + name="test_cert", + email="name@org.com", + city="Vegas", + province="NV", + country="US", + organization="FZ", + unit="IT", + version=1, + days_valid=365, + key_size=2048, + key_type="rsa", + store_tls=True, + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + mm = ModuleManager(module=module) + mm.client.platform = 'Velos Partition' + + mm.client.get = Mock(return_value={'code': 200, 'contents': {'f5-openconfig-aaa-tls:tls': {'config': {}}}}) + mm.client.post = Mock(return_value={'code': 200, 'contents': {}}) + + result = mm.exec_module() + + self.assertTrue(result['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 1) + + def test_create_cert_key_on_rseries(self, *args): + set_module_args(dict( + name="test_cert", + subject_alternative_name="DNS:www.example.com", + email="name@org.com", + city="Vegas", + province="NY", + country="US", + organization="FZ", + unit="IT", + version=1, + days_valid=365, + key_size=2048, + key_type="rsa", + store_tls=True, + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + mm = ModuleManager(module=module) + mm.client.platform = 'rSeries Platform' + + mm.client.get = Mock(return_value={'code': 200, 'contents': {'f5-openconfig-aaa-tls:tls': {'config': {}}}}) + mm.client.post = Mock(return_value={'code': 200, 'contents': {}}) + + result = mm.exec_module() + + self.assertTrue(result['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 1) + + def test_update_cert_key(self, *args): + set_module_args(dict( + name="test_cert", + city="Seattle", + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + existing_cert = load_fixture('f5os_get_tls_cert.json') + + mm = ModuleManager(module=module) + mm.client.platform = 'Velos Platform' + + mm.client.get = Mock(return_value={'code': 200, 'contents': existing_cert}) + mm.client.post = Mock(return_value={'code': 200, 'contents': {}}) + + result = mm.exec_module() + + self.assertTrue(result['changed']) + self.assertEqual(mm.client.post.call_count, 1) + self.assertEqual(mm.client.get.call_count, 2) + + def test_delete_cert_key(self, *args): + set_module_args(dict( + name="test_cert", + state='absent', + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + mm = ModuleManager(module=module) + mm.client.platform = 'Velos Platform' + + mm.exists = Mock(side_effect=[True, False]) + mm.client.put = Mock(return_value={'code': 200, 'contents': {}}) + + result = mm.exec_module() + + self.assertTrue(result['changed']) + self.assertEqual(mm.client.put.call_count, 1) + + @patch.object(f5os_tls_cert_key, 'Connection') + @patch.object(f5os_tls_cert_key.ModuleManager, 'exec_module', Mock(return_value={'changed': False})) + def test_main_function_success(self, *args): + set_module_args(dict( + name='foobar', + state='present', + )) + + with self.assertRaises(AnsibleExitJson) as result: + f5os_tls_cert_key.main() + + self.assertFalse(result.exception.args[0]['changed']) + + @patch.object(f5os_tls_cert_key, 'Connection') + @patch.object(f5os_tls_cert_key.ModuleManager, 'exec_module', + Mock(side_effect=F5ModuleError('This module has failed.')) + ) + def test_main_function_failed(self, *args): + set_module_args(dict( + name='foobar', + state='absent', + )) + + with self.assertRaises(AnsibleFailJson) as result: + f5os_tls_cert_key.main() + + self.assertTrue(result.exception.args[0]['failed']) + self.assertIn('This module has failed', result.exception.args[0]['msg']) + + def test_device_call_functions(self, *args): + set_module_args(dict( + name="test_cert", + email="name@org.com", + city="Vegas", + province="NV", + country="US", + organization="FZ", + unit="IT", + version=1, + days_valid=365, + key_size=2048, + key_type="rsa", + store_tls=True, + )) + + module = AnsibleModule( + argument_spec=self.spec.argument_spec, + supports_check_mode=self.spec.supports_check_mode, + required_if=self.spec.required_if, + mutually_exclusive=self.spec.mutually_exclusive, + ) + + mm = ModuleManager(module=module) + mm.client.platform = 'Velos Partition' + + mm.client.get = Mock( + side_effect=[ + {'code': 404, 'contents': {}}, + {'code': 503, 'contents': 'server error'}, + ] + ) + res1 = mm.exists() + self.assertFalse(res1) + + with self.assertRaises(F5ModuleError) as res2: + mm.exists() + self.assertIn("server error", res2.exception.args[0]) + + mm.exists = Mock(return_value=False) + res3 = mm.absent() + self.assertFalse(res3) + + mm._update_changed_options = Mock(return_value=False) + res4 = mm.should_update() + self.assertFalse(res4) + + mm.client.get = Mock(return_value={'code': 503, 'contents': 'server error'}) + with self.assertRaises(F5ModuleError) as res5: + mm.read_current_from_device() + self.assertIn("server error", res5.exception.args[0]) + + mm.client.put = Mock(return_value={'code': 503, 'contents': 'server error'}) + with self.assertRaises(F5ModuleError) as res6: + mm.remove_from_device() + self.assertIn("server error", res6.exception.args[0]) + + mm.remove_from_device = Mock() + mm.exists = Mock(return_value=True) + with self.assertRaises(F5ModuleError) as res7: + mm.remove() + self.assertIn("Failed to delete the resource.", res7.exception.args[0]) + + mm.read_current_from_device = Mock() + mm.should_update = Mock(return_value=False) + res8 = mm.update() + self.assertFalse(res8)