2
0
mirror of https://github.com/xcat2/confluent.git synced 2026-07-13 17:38:20 +00:00

Merge pull request #218 from gosforthcross/eureka-chassis-support

Add MEGWARE Eureka Chassis support + Small change for how IPs/Hostnames are handled in the redfish hardwaremanagement plugin
This commit is contained in:
Jarrod Johnson
2026-06-30 08:08:37 -04:00
committed by GitHub
9 changed files with 471 additions and 4 deletions
@@ -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,
}
@@ -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}
@@ -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)
@@ -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',
@@ -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}})
@@ -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
@@ -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:
+1
View File
@@ -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']),