diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 02f6408696..62a07052da 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1228,7 +1228,7 @@ macros: team_opennebula: ilicmilan meerkampdvv rsmontero xorel nilsding team_oracle: manojmeda mross22 nalsaber team_purestorage: bannaych dnix101 genegr lionmax opslounge raekins sdodsley sile16 - team_redfish: mraineri tomasg2012 xmadsen renxulei + team_redfish: mraineri tomasg2012 xmadsen renxulei rajeevkallur bhavya06 team_rhn: FlossWare alikins barnabycourt vritant team_scaleway: remyleone abarbare team_solaris: bcoca fishman jasperla jpdasma mator scathatheworm troy2914 xen0l diff --git a/plugins/module_utils/ilo_redfish_utils.py b/plugins/module_utils/ilo_redfish_utils.py new file mode 100644 index 0000000000..04b08ae52f --- /dev/null +++ b/plugins/module_utils/ilo_redfish_utils.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021-2022 Hewlett Packard Enterprise, Inc. All rights reserved. +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible_collections.community.general.plugins.module_utils.redfish_utils import RedfishUtils + + +class iLORedfishUtils(RedfishUtils): + + def get_ilo_sessions(self): + result = {} + # listing all users has always been slower than other operations, why? + session_list = [] + sessions_results = [] + # Get these entries, but does not fail if not found + properties = ['Description', 'Id', 'Name', 'UserName'] + + # Changed self.sessions_uri to Hardcoded string. + response = self.get_request( + self.root_uri + self.service_root + "SessionService/Sessions/") + if not response['ret']: + return response + result['ret'] = True + data = response['data'] + + if 'Oem' in data: + if data["Oem"]["Hpe"]["Links"]["MySession"]["@odata.id"]: + current_session = data["Oem"]["Hpe"]["Links"]["MySession"]["@odata.id"] + + for sessions in data[u'Members']: + # session_list[] are URIs + session_list.append(sessions[u'@odata.id']) + # for each session, get details + for uri in session_list: + session = {} + if uri != current_session: + response = self.get_request(self.root_uri + uri) + if not response['ret']: + return response + data = response['data'] + for property in properties: + if property in data: + session[property] = data[property] + sessions_results.append(session) + result["msg"] = sessions_results + result["ret"] = True + return result + + def set_ntp_server(self, mgr_attributes): + result = {} + setkey = mgr_attributes['mgr_attr_name'] + + nic_info = self.get_manager_ethernet_uri() + ethuri = nic_info["nic_addr"] + + response = self.get_request(self.root_uri + ethuri) + if not response['ret']: + return response + result['ret'] = True + data = response['data'] + payload = {"DHCPv4": { + "UseNTPServers": "" + }} + + if data["DHCPv4"]["UseNTPServers"]: + payload["DHCPv4"]["UseNTPServers"] = False + res_dhv4 = self.patch_request(self.root_uri + ethuri, payload) + if not res_dhv4['ret']: + return res_dhv4 + + payload = {"DHCPv6": { + "UseNTPServers": "" + }} + + if data["DHCPv6"]["UseNTPServers"]: + payload["DHCPv6"]["UseNTPServers"] = False + res_dhv6 = self.patch_request(self.root_uri + ethuri, payload) + if not res_dhv6['ret']: + return res_dhv6 + + datetime_uri = self.manager_uri + "DateTime" + + response = self.get_request(self.root_uri + datetime_uri) + if not response['ret']: + return response + + data = response['data'] + + ntp_list = data[setkey] + if(len(ntp_list) == 2): + ntp_list.pop(0) + + ntp_list.append(mgr_attributes['mgr_attr_value']) + + payload = {setkey: ntp_list} + + response1 = self.patch_request(self.root_uri + datetime_uri, payload) + if not response1['ret']: + return response1 + + return {'ret': True, 'changed': True, 'msg': "Modified %s" % mgr_attributes['mgr_attr_name']} + + def set_time_zone(self, attr): + key = attr['mgr_attr_name'] + + uri = self.manager_uri + "DateTime/" + response = self.get_request(self.root_uri + uri) + if not response['ret']: + return response + + data = response["data"] + + if key not in data: + return {'ret': False, 'changed': False, 'msg': "Key %s not found" % key} + + timezones = data["TimeZoneList"] + index = "" + for tz in timezones: + if attr['mgr_attr_value'] in tz["Name"]: + index = tz["Index"] + break + + payload = {key: {"Index": index}} + response = self.patch_request(self.root_uri + uri, payload) + if not response['ret']: + return response + + return {'ret': True, 'changed': True, 'msg': "Modified %s" % attr['mgr_attr_name']} + + def set_dns_server(self, attr): + key = attr['mgr_attr_name'] + nic_info = self.get_manager_ethernet_uri() + uri = nic_info["nic_addr"] + + response = self.get_request(self.root_uri + uri) + if not response['ret']: + return response + + data = response['data'] + + dns_list = data["Oem"]["Hpe"]["IPv4"][key] + + if len(dns_list) == 3: + dns_list.pop(0) + + dns_list.append(attr['mgr_attr_value']) + + payload = { + "Oem": { + "Hpe": { + "IPv4": { + key: dns_list + } + } + } + } + + response = self.patch_request(self.root_uri + uri, payload) + if not response['ret']: + return response + + return {'ret': True, 'changed': True, 'msg': "Modified %s" % attr['mgr_attr_name']} + + def set_domain_name(self, attr): + key = attr['mgr_attr_name'] + + nic_info = self.get_manager_ethernet_uri() + ethuri = nic_info["nic_addr"] + + response = self.get_request(self.root_uri + ethuri) + if not response['ret']: + return response + + data = response['data'] + + payload = {"DHCPv4": { + "UseDomainName": "" + }} + + if data["DHCPv4"]["UseDomainName"]: + payload["DHCPv4"]["UseDomainName"] = False + res_dhv4 = self.patch_request(self.root_uri + ethuri, payload) + if not res_dhv4['ret']: + return res_dhv4 + + payload = {"DHCPv6": { + "UseDomainName": "" + }} + + if data["DHCPv6"]["UseDomainName"]: + payload["DHCPv6"]["UseDomainName"] = False + res_dhv6 = self.patch_request(self.root_uri + ethuri, payload) + if not res_dhv6['ret']: + return res_dhv6 + + domain_name = attr['mgr_attr_value'] + + payload = {"Oem": { + "Hpe": { + key: domain_name + } + }} + + response = self.patch_request(self.root_uri + ethuri, payload) + if not response['ret']: + return response + return {'ret': True, 'changed': True, 'msg': "Modified %s" % attr['mgr_attr_name']} + + def set_wins_registration(self, mgrattr): + Key = mgrattr['mgr_attr_name'] + + nic_info = self.get_manager_ethernet_uri() + ethuri = nic_info["nic_addr"] + + payload = { + "Oem": { + "Hpe": { + "IPv4": { + Key: False + } + } + } + } + + response = self.patch_request(self.root_uri + ethuri, payload) + if not response['ret']: + return response + return {'ret': True, 'changed': True, 'msg': "Modified %s" % mgrattr['mgr_attr_name']} diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py index cd50b5ecd0..378d8fa9c3 100644 --- a/plugins/module_utils/redfish_utils.py +++ b/plugins/module_utils/redfish_utils.py @@ -1834,12 +1834,16 @@ class RedfishUtils(object): result['ret'] = True data = response['data'] - for device in data[u'Fans']: - fan = {} - for property in properties: - if property in device: - fan[property] = device[property] - fan_results.append(fan) + # Checking if fans are present + if u'Fans' in data: + for device in data[u'Fans']: + fan = {} + for property in properties: + if property in device: + fan[property] = device[property] + fan_results.append(fan) + else: + return {'ret': False, 'msg': "No Fans present"} result["entries"] = fan_results return result @@ -2701,39 +2705,14 @@ class RedfishUtils(object): return self.aggregate_managers(self.get_manager_health_report) def set_manager_nic(self, nic_addr, nic_config): - # Get EthernetInterface collection - response = self.get_request(self.root_uri + self.manager_uri) - if response['ret'] is False: - return response - data = response['data'] - if 'EthernetInterfaces' not in data: - return {'ret': False, 'msg': "EthernetInterfaces resource not found"} - ethernetinterfaces_uri = data["EthernetInterfaces"]["@odata.id"] - response = self.get_request(self.root_uri + ethernetinterfaces_uri) - if response['ret'] is False: - return response - data = response['data'] - uris = [a.get('@odata.id') for a in data.get('Members', []) if - a.get('@odata.id')] + # Get the manager ethernet interface uri + nic_info = self.get_manager_ethernet_uri(nic_addr) - # Find target EthernetInterface - target_ethernet_uri = None - target_ethernet_current_setting = None - if nic_addr == 'null': - # Find root_uri matched EthernetInterface when nic_addr is not specified - nic_addr = (self.root_uri).split('/')[-1] - nic_addr = nic_addr.split(':')[0] # split port if existing - for uri in uris: - response = self.get_request(self.root_uri + uri) - if response['ret'] is False: - return response - data = response['data'] - if '"' + nic_addr.lower() + '"' in str(data).lower() or "'" + nic_addr.lower() + "'" in str(data).lower(): - target_ethernet_uri = uri - target_ethernet_current_setting = data - break - if target_ethernet_uri is None: - return {'ret': False, 'msg': "No matched EthernetInterface found under Manager"} + if nic_info.get('nic_addr') is None: + return nic_info + else: + target_ethernet_uri = nic_info['nic_addr'] + target_ethernet_current_setting = nic_info['ethernet_setting'] # Convert input to payload and check validity payload = {} @@ -2797,6 +2776,50 @@ class RedfishUtils(object): return response return {'ret': True, 'changed': True, 'msg': "Modified Manager NIC"} + # A helper function to get the EthernetInterface URI + def get_manager_ethernet_uri(self, nic_addr='null'): + # Get EthernetInterface collection + response = self.get_request(self.root_uri + self.manager_uri) + if not response['ret']: + return response + data = response['data'] + if 'EthernetInterfaces' not in data: + return {'ret': False, 'msg': "EthernetInterfaces resource not found"} + ethernetinterfaces_uri = data["EthernetInterfaces"]["@odata.id"] + response = self.get_request(self.root_uri + ethernetinterfaces_uri) + if not response['ret']: + return response + data = response['data'] + uris = [a.get('@odata.id') for a in data.get('Members', []) if + a.get('@odata.id')] + + # Find target EthernetInterface + target_ethernet_uri = None + target_ethernet_current_setting = None + if nic_addr == 'null': + # Find root_uri matched EthernetInterface when nic_addr is not specified + nic_addr = (self.root_uri).split('/')[-1] + nic_addr = nic_addr.split(':')[0] # split port if existing + for uri in uris: + response = self.get_request(self.root_uri + uri) + if not response['ret']: + return response + data = response['data'] + data_string = json.dumps(data) + if nic_addr.lower() in data_string.lower(): + target_ethernet_uri = uri + target_ethernet_current_setting = data + break + + nic_info = {} + nic_info['nic_addr'] = target_ethernet_uri + nic_info['ethernet_setting'] = target_ethernet_current_setting + + if target_ethernet_uri is None: + return {} + else: + return nic_info + def set_hostinterface_attributes(self, hostinterface_config, hostinterface_id=None): response = self.get_request(self.root_uri + self.manager_uri) if response['ret'] is False: diff --git a/plugins/modules/ilo_redfish_config.py b/plugins/modules/ilo_redfish_config.py new file mode 120000 index 0000000000..b1846d51fd --- /dev/null +++ b/plugins/modules/ilo_redfish_config.py @@ -0,0 +1 @@ +remote_management/redfish/ilo_redfish_config.py \ No newline at end of file diff --git a/plugins/modules/ilo_redfish_info.py b/plugins/modules/ilo_redfish_info.py new file mode 120000 index 0000000000..45790c3add --- /dev/null +++ b/plugins/modules/ilo_redfish_info.py @@ -0,0 +1 @@ +remote_management/redfish/ilo_redfish_info.py \ No newline at end of file diff --git a/plugins/modules/remote_management/redfish/ilo_redfish_config.py b/plugins/modules/remote_management/redfish/ilo_redfish_config.py new file mode 100644 index 0000000000..79a7a78584 --- /dev/null +++ b/plugins/modules/remote_management/redfish/ilo_redfish_config.py @@ -0,0 +1,175 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021-2022 Hewlett Packard Enterprise, Inc. All rights reserved. +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: ilo_redfish_config +short_description: Sets or updates configuration attributes on HPE iLO with Redfish OEM extensions +version_added: 4.2.0 +description: + - Builds Redfish URIs locally and sends them to iLO to + set or update a configuration attribute. + - For use with HPE iLO operations that require Redfish OEM extensions. +options: + category: + required: true + type: str + description: + - Command category to execute on iLO. + choices: ['Manager'] + command: + required: true + description: + - List of commands to execute on iLO. + type: list + elements: str + baseuri: + required: true + description: + - Base URI of iLO. + type: str + username: + description: + - User for authentication with iLO. + type: str + password: + description: + - Password for authentication with iLO. + type: str + auth_token: + description: + - Security token for authentication with OOB controller. + type: str + timeout: + description: + - Timeout in seconds for URL requests to iLO controller. + default: 10 + type: int + attribute_name: + required: true + description: + - Name of the attribute to be configured. + type: str + attribute_value: + required: false + description: + - Value of the attribute to be configured. + type: str +author: + - "Bhavya B (@bhavya06)" +''' + +EXAMPLES = ''' + - name: Disable WINS Registration + community.general.ilo_redfish_config: + category: Manager + command: SetWINSReg + baseuri: 15.X.X.X + username: Admin + password: Testpass123 + attribute_name: WINSRegistration + + - name: Set Time Zone + community.general.ilo_redfish_config: + category: Manager + command: SetTimeZone + baseuri: 15.X.X.X + username: Admin + password: Testpass123 + attribute_name: TimeZone + attribute_value: Chennai +''' + +RETURN = ''' +msg: + description: Message with action result or error description + returned: always + type: str + sample: "Action was successful" +''' + +CATEGORY_COMMANDS_ALL = { + "Manager": ["SetTimeZone", "SetDNSserver", "SetDomainName", "SetNTPServers", "SetWINSReg"] +} + +from ansible_collections.community.general.plugins.module_utils.ilo_redfish_utils import iLORedfishUtils +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native + + +def main(): + result = {} + module = AnsibleModule( + argument_spec=dict( + category=dict(required=True, choices=list( + CATEGORY_COMMANDS_ALL.keys())), + command=dict(required=True, type='list', elements='str'), + baseuri=dict(required=True), + username=dict(), + password=dict(no_log=True), + auth_token=dict(no_log=True), + attribute_name=dict(required=True), + attribute_value=dict(), + timeout=dict(type='int', default=10) + ), + required_together=[ + ('username', 'password'), + ], + required_one_of=[ + ('username', 'auth_token'), + ], + mutually_exclusive=[ + ('username', 'auth_token'), + ], + supports_check_mode=False + ) + + category = module.params['category'] + command_list = module.params['command'] + + creds = {"user": module.params['username'], + "pswd": module.params['password'], + "token": module.params['auth_token']} + + timeout = module.params['timeout'] + + root_uri = "https://" + module.params['baseuri'] + rf_utils = iLORedfishUtils(creds, root_uri, timeout, module) + mgr_attributes = {'mgr_attr_name': module.params['attribute_name'], + 'mgr_attr_value': module.params['attribute_value']} + changed = False + + offending = [ + cmd for cmd in command_list if cmd not in CATEGORY_COMMANDS_ALL[category]] + + if offending: + module.fail_json(msg=to_native("Invalid Command(s): '%s'. Allowed Commands = %s" % ( + offending, CATEGORY_COMMANDS_ALL[category]))) + + if category == "Manager": + resource = rf_utils._find_managers_resource() + if not resource['ret']: + module.fail_json(msg=to_native(resource['msg'])) + + dispatch = dict( + SetTimeZone=rf_utils.set_time_zone, + SetDNSserver=rf_utils.set_dns_server, + SetDomainName=rf_utils.set_domain_name, + SetNTPServers=rf_utils.set_ntp_server, + SetWINSReg=rf_utils.set_wins_registration + ) + + for command in command_list: + result[command] = dispatch[command](mgr_attributes) + if 'changed' in result[command]: + changed |= result[command]['changed'] + + module.exit_json(ilo_redfish_config=result, changed=changed) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/remote_management/redfish/ilo_redfish_info.py b/plugins/modules/remote_management/redfish/ilo_redfish_info.py new file mode 100644 index 0000000000..2ac61fcea6 --- /dev/null +++ b/plugins/modules/remote_management/redfish/ilo_redfish_info.py @@ -0,0 +1,186 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021-2022 Hewlett Packard Enterprise, Inc. All rights reserved. +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: ilo_redfish_info +short_description: Gathers server information through iLO using Redfish APIs +version_added: 4.2.0 +description: + - Builds Redfish URIs locally and sends them to iLO to + get information back. + - For use with HPE iLO operations that require Redfish OEM extensions. +options: + category: + required: true + description: + - List of categories to execute on iLO. + type: list + elements: str + command: + required: true + description: + - List of commands to execute on iLO. + type: list + elements: str + baseuri: + required: true + description: + - Base URI of iLO. + type: str + username: + description: + - User for authentication with iLO. + type: str + password: + description: + - Password for authentication with iLO. + type: str + auth_token: + description: + - Security token for authentication with iLO. + type: str + timeout: + description: + - Timeout in seconds for URL requests to iLO. + default: 10 + type: int +author: + - "Bhavya B (@bhavya06)" +''' + +EXAMPLES = ''' + - name: Get iLO Sessions + community.general.ilo_redfish_info: + category: Sessions + command: GetiLOSessions + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + register: result_sessions +''' + +RETURN = ''' +ilo_redfish_info: + description: Returns iLO sessions. + type: dict + contains: + GetiLOSessions: + description: Returns the iLO session msg and whether the function executed successfully. + type: dict + contains: + ret: + description: Check variable to see if the information was succesfully retrived. + type: bool + msg: + description: Information of all active iLO sessions. + type: list + elements: dict + contains: + Description: + description: Provides a description of the resource. + type: str + Id: + description: The sessionId. + type: str + Name: + description: The name of the resource. + type: str + UserName: + description: Name to use to log in to the management processor. + type: str + returned: always +''' + +CATEGORY_COMMANDS_ALL = { + "Sessions": ["GetiLOSessions"] +} + +CATEGORY_COMMANDS_DEFAULT = { + "Sessions": "GetiLOSessions" +} + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.general.plugins.module_utils.ilo_redfish_utils import iLORedfishUtils + + +def main(): + result = {} + category_list = [] + module = AnsibleModule( + argument_spec=dict( + category=dict(required=True, type='list', elements='str'), + command=dict(required=True, type='list', elements='str'), + baseuri=dict(required=True), + username=dict(), + password=dict(no_log=True), + auth_token=dict(no_log=True), + timeout=dict(type='int', default=10) + ), + required_together=[ + ('username', 'password'), + ], + required_one_of=[ + ('username', 'auth_token'), + ], + mutually_exclusive=[ + ('username', 'auth_token'), + ], + supports_check_mode=True + ) + + creds = {"user": module.params['username'], + "pswd": module.params['password'], + "token": module.params['auth_token']} + + timeout = module.params['timeout'] + + root_uri = "https://" + module.params['baseuri'] + rf_utils = iLORedfishUtils(creds, root_uri, timeout, module) + + # Build Category list + if "all" in module.params['category']: + for entry in CATEGORY_COMMANDS_ALL: + category_list.append(entry) + else: + # one or more categories specified + category_list = module.params['category'] + + for category in category_list: + command_list = [] + # Build Command list for each Category + if category in CATEGORY_COMMANDS_ALL: + if not module.params['command']: + # True if we don't specify a command --> use default + command_list.append(CATEGORY_COMMANDS_DEFAULT[category]) + elif "all" in module.params['command']: + for entry in CATEGORY_COMMANDS_ALL[category]: + command_list.append(entry) + # one or more commands + else: + command_list = module.params['command'] + # Verify that all commands are valid + for cmd in command_list: + # Fail if even one command given is invalid + if cmd not in CATEGORY_COMMANDS_ALL[category]: + module.fail_json(msg="Invalid Command: %s" % cmd) + else: + # Fail if even one category given is invalid + module.fail_json(msg="Invalid Category: %s" % category) + + # Organize by Categories / Commands + if category == "Sessions": + for command in command_list: + if command == "GetiLOSessions": + result[command] = rf_utils.get_ilo_sessions() + + module.exit_json(ilo_redfish_info=result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/ilo_redfish_config/aliases b/tests/integration/targets/ilo_redfish_config/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/ilo_redfish_config/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/ilo_redfish_config/tasks/main.yml b/tests/integration/targets/ilo_redfish_config/tasks/main.yml new file mode 100644 index 0000000000..97d066c6bf --- /dev/null +++ b/tests/integration/targets/ilo_redfish_config/tasks/main.yml @@ -0,0 +1,48 @@ +- name: Set NTP Servers + ilo_redfish_config: + category: Manager + command: SetNTPServers + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + attribute_name: StaticNTPServers + attribute_value: 1.2.3.4 + +- name: Set DNS Server + ilo_redfish_config: + category: Manager + command: SetDNSserver + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + attribute_name: DNSServers + attribute_value: 192.168.1.1 + +- name: Set Domain name + ilo_redfish_config: + category: Manager + command: SetDomainName + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + attribute_name: DomainName + attribute_value: tst.sgp.hp.mfg + +- name: Disable WINS Reg + ilo_redfish_config: + category: Manager + command: SetWINSReg + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + attribute_name: WINSRegistration + +- name: Set TimeZone + ilo_redfish_config: + category: Manager + command: SetTimeZone + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + attribute_name: TimeZone + attribute_value: Chennai diff --git a/tests/integration/targets/ilo_redfish_info/aliases b/tests/integration/targets/ilo_redfish_info/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/ilo_redfish_info/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/ilo_redfish_info/tasks/main.yml b/tests/integration/targets/ilo_redfish_info/tasks/main.yml new file mode 100644 index 0000000000..664a677cec --- /dev/null +++ b/tests/integration/targets/ilo_redfish_info/tasks/main.yml @@ -0,0 +1,8 @@ +- name: Get sessions + ilo_redfish_info: + category: Sessions + command: GetiLOSessions + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + register: result_sessions