diff --git a/confluent_server/aiohmi/redfish/oem/lookup.py b/confluent_server/aiohmi/redfish/oem/lookup.py index 07bd4767..210f2dc2 100644 --- a/confluent_server/aiohmi/redfish/oem/lookup.py +++ b/confluent_server/aiohmi/redfish/oem/lookup.py @@ -16,12 +16,14 @@ import aiohmi.redfish.oem.dell.main as dell import aiohmi.redfish.oem.generic as generic import aiohmi.redfish.oem.lenovo.main as lenovo import aiohmi.redfish.oem.ami.main as ami +import aiohmi.redfish.oem.megware.main as megware OEMMAP = { 'Lenovo': lenovo, 'Dell': dell, 'AMI': ami, 'Ami': ami, + 'Megware': megware, } diff --git a/confluent_server/aiohmi/redfish/oem/megware/__init__.py b/confluent_server/aiohmi/redfish/oem/megware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/confluent_server/aiohmi/redfish/oem/megware/eureka.py b/confluent_server/aiohmi/redfish/oem/megware/eureka.py new file mode 100644 index 00000000..0c377099 --- /dev/null +++ b/confluent_server/aiohmi/redfish/oem/megware/eureka.py @@ -0,0 +1,154 @@ +# Copyright 2026 MEGWARE Computer Vertrieb und Service GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OEM handler for Megware EUREKA chassis Redfish service. + +The EUREKA chassis is a software-only Redfish service (not a BMC). +It manages up to 12 nodes with individual BMCs and up to 10 PSUs. +""" + +import aiohmi.redfish.oem.generic as generic +import aiohmi.exceptions as exc +import aiohmi.constants as const + + +class OEMHandler(generic.OEMHandler): + usegenericsensors = True + + async def get_default_sysurl(self): + """Return the system URL for the first available node. + + The EUREKA enclosure has up to 12 nodes (Node1-Node12). + Returns the first enabled/inserted node, or the first node overall. + """ + if not self._varsysurl and 'Systems' in self._rootinfo: + systems = self._rootinfo['Systems']['@odata.id'] + res = await self.webclient.grab_json_response_with_status(systems) + if res[1] == 401: + raise exc.PyghmiException('Access Denied') + elif res[1] < 200 or res[1] >= 300: + raise exc.PyghmiException(repr(res[0])) + members = res[0].get('Members', []) + if len(members) == 1: + self._varsysurl = members[0]['@odata.id'] + elif len(members) > 1: + for member in members: + murlinfo, status = await self.webclient.grab_json_response_with_status( + member['@odata.id']) + if status == 200: + if murlinfo.get('Status', {}).get('State') == 'Enabled': + self._varsysurl = member['@odata.id'] + break + if not self._varsysurl: + self._varsysurl = members[0]['@odata.id'] + return self._varsysurl + + async def get_system_power_watts(self, fishclient): + """Read node input power from EUREKA sensor endpoints. + + The EUREKA chassis reports node power via Node{N}InputPower + sensors under /redfish/v1/Chassis/1/Sensors/ rather than + standard Redfish PowerControl. + """ + totalwatts = 0 + gotpower = False + for sysurl in self._allsysurls: + nodeid = sysurl.rstrip('/').rsplit('/', 1)[-1] + nodeid = nodeid.replace('Node', '') + if not nodeid.isdigit(): + continue + try: + sensor_url = '/redfish/v1/Chassis/1/Sensors/Node{}InputPower'.format(nodeid) + sensor = await fishclient._do_web_request(sensor_url) + if sensor and 'Reading' in sensor: + totalwatts += float(sensor['Reading']) + gotpower = True + except Exception: + pass + if not gotpower: + raise exc.UnsupportedFunctionality( + 'Node power sensors not available') + return totalwatts + + async def _get_cpu_temps(self, fishclient): + """Read CPU temperatures from EUREKA BMC sensor endpoints. + + Reads BMC{N}CPU0Temp and BMC{N}CPU1Temp sensors from + /redfish/v1/Chassis/1/Sensors/. + """ + cputemps = [] + for sysurl in self._allsysurls: + nodeid = sysurl.rstrip('/').rsplit('/', 1)[-1] + nodeid = nodeid.replace('Node', '') + if not nodeid.isdigit(): + continue + for cpu in ('CPU0', 'CPU1'): + try: + sensor_url = '/redfish/v1/Chassis/1/Sensors/BMC{}Cpu{}Temp'.format(nodeid, cpu) + sensor = await fishclient._do_web_request(sensor_url) + if sensor and 'Reading' in sensor: + cputemps.append({ + 'name': 'CPU {} Node {}'.format(cpu, nodeid), + 'value': float(sensor['Reading']), + 'state_ids': [], + 'units': const.SensorUnits.Celsius, + 'imprecision': None, + }) + except Exception: + pass + return cputemps + + async def reseat_bay(self, bay): + """Power cycle a specific node in the EUREKA enclosure. + + Uses ComputerSystem.Reset with ForceRestart on the target node. + bay=-1 (enclosure-level) is not supported. + """ + if bay == -1: + raise exc.UnsupportedFunctionality( + 'Enclosure-level reset is not supported') + nodeurl = '/redfish/v1/Systems/Node{}'.format(bay) + await self._do_web_request( + nodeurl + '/Actions/ComputerSystem.Reset', + {'ResetType': 'ForceRestart'}, + method='POST') + + async def get_health(self, fishclient, verbose=True): + """Gather health status for the EUREKA chassis and all nodes.""" + issues = [] + try: + chassis = await self._do_web_request('/redfish/v1/Chassis/1') + health = chassis.get('Status', {}).get('Health', 'OK') + if health != 'OK': + issues.append('Chassis health: {}'.format(health)) + except Exception: + issues.append('Cannot reach chassis health endpoint') + + for sysurl in self._allsysurls: + try: + sysinfo = await self._do_web_request(sysurl) + except Exception: + continue + state = sysinfo.get('Status', {}).get('State', 'Absent') + name = sysurl.rstrip('/').rsplit('/', 1)[-1] + if state == 'Absent': + issues.append('{}: Absent'.format(name)) + elif state != 'Enabled': + issues.append('{}: {}'.format(name, state)) + + health = 0 + if issues: + health = 1 + return {'badreadings': [], 'health': health} diff --git a/confluent_server/aiohmi/redfish/oem/megware/main.py b/confluent_server/aiohmi/redfish/oem/megware/main.py new file mode 100644 index 00000000..fc2b324d --- /dev/null +++ b/confluent_server/aiohmi/redfish/oem/megware/main.py @@ -0,0 +1,23 @@ +# Copyright 2026 MEGWARE Computer Vertrieb und Service GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OEM handler for Megware EUREKA chassis Redfish service. +""" + +import aiohmi.redfish.oem.megware.eureka as eureka + + +async def get_handler(sysinfo, sysurl, webclient, cache, cmd, rootinfo={}): + return await eureka.OEMHandler.create(sysinfo, sysurl, webclient, cache, gpool=cmd._gpool) diff --git a/confluent_server/confluent/discovery/core.py b/confluent_server/confluent/discovery/core.py index ab744f57..5c9b17eb 100644 --- a/confluent_server/confluent/discovery/core.py +++ b/confluent_server/confluent/discovery/core.py @@ -78,6 +78,7 @@ import confluent.discovery.handlers.xcc as xcc import confluent.discovery.handlers.xcc3 as xcc3 import confluent.discovery.handlers.smm3 as smm3 import confluent.discovery.handlers.megarac as megarac +import confluent.discovery.handlers.eureka as eureka import confluent.exceptions as exc import confluent.log as log import confluent.messages as msg @@ -121,6 +122,7 @@ nodehandlers = { 'lenovo-xcc': xcc, 'lenovo-xcc3': xcc3, 'megarac-bmc': megarac, + 'megware-chassis': eureka, 'service:management-hardware.IBM:integrated-management-module2': imm, 'pxe-client': pxeh, 'onie-switch': None, @@ -143,6 +145,7 @@ servicenames = { 'lenovo-xcc': 'lenovo-xcc', 'lenovo-xcc3': 'lenovo-xcc3', 'megarac-bmc': 'megarac-bmc', + 'megware-chassis': 'megware-chassis', #'openbmc': 'openbmc', 'service:management-hardware.IBM:integrated-management-module2': 'lenovo-imm2', 'service:io-device.Lenovo:management-module': 'lenovo-switch', @@ -161,6 +164,7 @@ servicebyname = { 'lenovo-xcc': 'lenovo-xcc', 'lenovo-xcc3': 'lenovo-xcc3', 'megarac-bmc': 'megarac-bmc', + 'megware-chassis': 'megware-chassis', 'lenovo-imm2': 'service:management-hardware.IBM:integrated-management-module2', 'lenovo-switch': 'service:io-device.Lenovo:management-module', 'thinkagile-storage': 'service:thinkagile-storagebmc', diff --git a/confluent_server/confluent/discovery/handlers/eureka.py b/confluent_server/confluent/discovery/handlers/eureka.py new file mode 100644 index 00000000..b9c6a58c --- /dev/null +++ b/confluent_server/confluent/discovery/handlers/eureka.py @@ -0,0 +1,242 @@ +# Copyright 2026 MEGWARE Computer Vertrieb und Service GmbH +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Discovery handler for Megware EUREKA chassis Redfish service. + +This is a software-only Redfish service (not a BMC). It provides +chassis-level information, node inventory, power supply data, and +sensor readings. It does NOT support IPMI, NIC configuration, +firmware update, or other BMC-specific operations. +""" + +import asyncio +import confluent.discovery.handlers.generic as generic +import confluent.util as util +import aiohmi.util.webclient as webclient + + +class NodeHandler(generic.NodeHandler): + devname = 'EUREKA' + is_enclosure = True + maxmacs = 42 + https_supported = True + DEFAULT_USER = 'admin' + DEFAULT_PASS = 'admin' + + def __init__(self, info, configmanager): + self.trieddefault = None + self.targuser = None + self.curruser = None + self.currpass = None + self.targpass = None + self.nodename = None + self.xauthtoken = None + self.atdefault = True + super(NodeHandler, self).__init__(info, configmanager) + + def get_firmware_default_account_info(self): + return (self.DEFAULT_USER, self.DEFAULT_PASS) + + async def scan(self): + await self.get_https_cert() + try: + wc = webclient.WebConnection( + self.ipaddr, 443, verifycallback=self.validate_cert) + root = await wc.grab_json_response('/redfish/v1/') + uuid = root.get('UUID', None) + if uuid: + self.info['uuid'] = uuid.lower() + + # Read chassis info for model/serial + chassis_url = root.get('Chassis', {}).get('@odata.id') + if chassis_url: + try: + chassis_coll = await wc.grab_json_response(chassis_url) + members = chassis_coll.get('Members', []) + if members: + cinfo = await wc.grab_json_response(members[0]['@odata.id']) + if cinfo.get('Model'): + self.info['modelname'] = cinfo['Model'] + if cinfo.get('SerialNumber'): + self.info['serialnumber'] = cinfo['SerialNumber'] + if cinfo.get('Manufacturer'): + self.info['modelnumber'] = cinfo['Manufacturer'] + except Exception: + pass + except Exception: + pass + + async def _get_wc(self): + await self.get_https_cert() + defuser, defpass = self.get_firmware_default_account_info() + wc = webclient.WebConnection( + self.ipaddr, 443, verifycallback=self.validate_cert) + wc.set_header('Content-Type', 'application/json') + wc.set_header('Accept', 'application/json') + wc.set_header('OData-Version', '4.0') + + if not self.trieddefault: + # Attempt authentication with default credentials + wc.set_basic_credentials(defuser, defpass) + body, status, headers = await wc.grab_response_with_status( + '/redfish/v1/SessionService/Sessions', + {'UserName': defuser, 'Password': defpass}) + if status == 201: + token = headers.get('X-Auth-Token') + if token: + self.curruser = defuser + self.currpass = defpass + self.xauthtoken = token + if 'Authorization' in wc.stdheaders: + del wc.stdheaders['Authorization'] + wc.stdheaders['X-Auth-Token'] = token + return wc + elif status == 401: + self.trieddefault = True + try: + errinfo = util.json_loads(body) + for msg in errinfo.get('@Message.ExtendedInfo', []): + if 'PasswordChangeRequired' in msg.get('MessageId', ''): + chgurl = msg.get('MessageArgs', [None])[0] + if chgurl and self.targpass and self.targpass != defpass: + wc.set_basic_credentials(defuser, defpass) + wc.set_header('If-Match', '*') + rsp, chgstatus = await wc.grab_json_response_with_status( + chgurl, + {'Password': self.targpass}, + method='PATCH') + if chgstatus >= 200 and chgstatus < 300: + body, status, headers = \ + await wc.grab_response_with_status( + '/redfish/v1/SessionService/Sessions', + {'UserName': defuser, + 'Password': self.targpass}) + if status == 201: + token = headers.get('X-Auth-Token') + if token: + self.curruser = defuser + self.currpass = self.targpass + self.xauthtoken = token + if 'Authorization' in wc.stdheaders: + del wc.stdheaders['Authorization'] + wc.stdheaders['X-Auth-Token'] = token + return wc + except Exception: + pass + # If no password change scenario, try target password + if self.targuser and self.targpass: + wc.set_basic_credentials(self.targuser, self.targpass) + body, status, headers = await wc.grab_response_with_status( + '/redfish/v1/SessionService/Sessions', + {'UserName': self.targuser, 'Password': self.targpass}) + if status == 201: + token = headers.get('X-Auth-Token') + if token: + self.curruser = self.targuser + self.currpass = self.targpass + self.xauthtoken = token + if 'Authorization' in wc.stdheaders: + del wc.stdheaders['Authorization'] + wc.stdheaders['X-Auth-Token'] = token + return wc + self.trieddefault = True + return None + + # We have previously established credentials, try to reuse + if self.curruser and self.currpass: + wc.set_basic_credentials(self.curruser, self.currpass) + body, status, headers = await wc.grab_response_with_status( + '/redfish/v1/SessionService/Sessions', + {'UserName': self.curruser, 'Password': self.currpass}) + if status == 201: + token = headers.get('X-Auth-Token') + if token: + self.xauthtoken = token + if 'Authorization' in wc.stdheaders: + del wc.stdheaders['Authorization'] + wc.stdheaders['X-Auth-Token'] = token + return wc + + # Try target credentials + if self.targuser and self.targpass: + wc.set_basic_credentials(self.targuser, self.targpass) + body, status, headers = await wc.grab_response_with_status( + '/redfish/v1/SessionService/Sessions', + {'UserName': self.targuser, 'Password': self.targpass}) + if status == 201: + token = headers.get('X-Auth-Token') + if token: + self.curruser = self.targuser + self.currpass = self.targpass + self.xauthtoken = token + if 'Authorization' in wc.stdheaders: + del wc.stdheaders['Authorization'] + wc.stdheaders['X-Auth-Token'] = token + return wc + + return None + + async def config(self, nodename): + self.nodename = nodename + creds = self.configmanager.get_node_attributes( + nodename, + ['secret.hardwaremanagementuser', + 'secret.hardwaremanagementpassword'], + True) + cd = creds.get(nodename, {}) + defuser, defpass = self.get_firmware_default_account_info() + user, passwd, _ = self.get_node_credentials( + nodename, creds, defuser, defpass) + user = util.stringify(user) + passwd = util.stringify(passwd) + self.targuser = user + self.targpass = passwd + + wc = await self._get_wc() + if wc is None: + raise Exception('Unable to authenticate to EUREKA chassis') + + # Update credentials if they differ from current + authupdate = {} + if user != self.curruser: + authupdate['UserName'] = user + if passwd != self.currpass: + authupdate['Password'] = passwd + if authupdate: + srvroot = await wc.grab_json_response('/redfish/v1/') + asrv = srvroot.get('AccountService', {}).get('@odata.id') + if asrv: + acctinfo = await wc.grab_json_response(asrv) + acctsurl = acctinfo.get('Accounts', {}).get('@odata.id') + if acctsurl: + accts = await wc.grab_json_response(acctsurl) + for acctref in accts.get('Members', []): + accturl = acctref.get('@odata.id', '') + if accturl: + acctdata, acctstatus = \ + await wc.grab_json_response_with_status(accturl) + if acctdata.get('UserName') == self.curruser: + wc.set_header('If-Match', '*') + rsp, status = await wc.grab_json_response_with_status( + accturl, authupdate, method='PATCH') + if status >= 200 and status < 300: + self.curruser = user + self.currpass = passwd + break + + # Store link-local address if applicable + if self.ipaddr.startswith('fe80::'): + await self.configmanager.set_node_attributes( + {nodename: {'hardwaremanagement.manager': self.ipaddr}}) diff --git a/confluent_server/confluent/discovery/protocols/ssdp.py b/confluent_server/confluent/discovery/protocols/ssdp.py index fc89aaaa..a6e77b8e 100644 --- a/confluent_server/confluent/discovery/protocols/ssdp.py +++ b/confluent_server/confluent/discovery/protocols/ssdp.py @@ -119,6 +119,9 @@ def _process_snoop(peer, rsp, mac, known_peers, newmacs, peerbymacaddress, byeha elif value.endswith('/DeviceDescription.json'): targurl = '/DeviceDescription.json' targtype = 'lenovo-xcc' + elif value.endswith('/redfish/v1/'): + targurl = '/redfish/v1/' + targtype = 'redfish-server' else: return if handler: @@ -527,12 +530,26 @@ async def check_fish(urldata, port=443, verifycallback=None): data['services'] = ['lenovo-smm3'] data['attributes'] = peerinfo return data + # NOTE: Fallback for generic redfish services without DeviceDescription.json or other explicit identifier + try: + peerinfo = await wc.grab_json_response('/redfish/v1/') + if peerinfo and 'UUID' in peerinfo: + data['services'] = [targtype] + data['uuid'] = peerinfo['UUID'].lower() + return data + except Exception: + pass return None url = '/redfish/v1/' peerinfo = await wc.grab_json_response('/redfish/v1/') if url == '/redfish/v1/': if 'UUID' in peerinfo: - data['services'] = [targtype] + vendor = peerinfo.get('Vendor', '') + # NOTE: Specific path for EUREKA MEGWARE Chassis + if vendor == 'Megware': + data['services'] = ['megware-chassis'] + else: + data['services'] = [targtype] data['uuid'] = peerinfo['UUID'].lower() return data return None diff --git a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py index 37cde652..26e39265 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/redfish.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/redfish.py @@ -230,12 +230,35 @@ def get_conn_params(node, configdata): else: bmc = node bmc = bmc.split('/', 1)[0] - # TODO(jbjohnso): check if the end has some number after a : without [] - # for non default port + # NOTE (tselle/gosforthcross): Set default port and try to get port via ':' split notation if available, handles IPv6 and IPv4, does not handle malformed IPs well, + # but does strip whitespace for possible problems with whitespace in IPs. + bmc = bmc.strip() + port = 443 + if bmc.startswith('['): + bracket_end = bmc.find(']') + if bracket_end > 0: + if len(bmc) > bracket_end + 1 and bmc[bracket_end + 1] == ':': + try: + port = int(bmc[bracket_end + 2:]) + except (ValueError, TypeError): + pass + bmc = bmc[:bracket_end + 1] + elif bmc.count(':') == 1: + hostpart, _, portstr = bmc.rpartition(':') + try: + port = int(portstr) + except (ValueError, TypeError): + pass + bmc = hostpart + if not 0 < port <= 65535: + # NOTE: Fallback to 443 if port read is not valid + port = 443 + return { 'username': username, 'passphrase': passphrase, 'bmc': bmc, + 'port': port, } @@ -384,7 +407,8 @@ class IpmiHandler: persistent_ipmicmds[(node, tenant)] = await IpmiCommandWrapper.create( node, cfg, bmc=connparams['bmc'], userid=connparams['username'], - password=connparams['passphrase']) + password=connparams['passphrase'], + port=connparams['port']) self.loggedin = True self.ipmicmd = persistent_ipmicmds[(node, tenant)] except socket.gaierror as ge: diff --git a/confluent_server/setup.py.tmpl b/confluent_server/setup.py.tmpl index 1cfe357f..fc481798 100644 --- a/confluent_server/setup.py.tmpl +++ b/confluent_server/setup.py.tmpl @@ -34,6 +34,7 @@ setup( 'aiohmi/redfish/oem/dell', 'aiohmi/redfish/oem/lenovo', 'aiohmi/redfish/oem/ami', + 'aiohmi/redfish/oem/megware', 'aiohmi/util'], scripts=['bin/confluent', 'bin/confluent_selfcheck', 'bin/confluentdbutil', 'bin/collective', 'bin/osdeploy'], data_files=[('/etc/init.d', ['sysvinit/confluent']),