From caa857ead03de00cf484aaf442d087903338a11e Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 02:51:33 +0200 Subject: [PATCH 1/6] Gather every event log a platform keeps, wherever it keeps it A read only ever looked at the manager's log services, and fell back to the system's when the manager published none. A platform that keeps an event log in both places had the second one invisible: an AMI MegaRAC keeps power unit and thermal events in a chassis log that nothing read, 103 records that no command could reach. Clearing deliberately does not follow. It stays where it was, so a log that only a read reaches is never destroyed by one, and clearing a platform that keeps its only event log on the system still works. The name test now ignores spacing, since a build that calls its post code log "BIOS POST Code Log" was read as an event log and merged 2719 post codes in. --- .../aiohmi/redfish/oem/generic.py | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/confluent_server/aiohmi/redfish/oem/generic.py b/confluent_server/aiohmi/redfish/oem/generic.py index 7ce2f84d..c5cd799e 100644 --- a/confluent_server/aiohmi/redfish/oem/generic.py +++ b/confluent_server/aiohmi/redfish/oem/generic.py @@ -570,13 +570,45 @@ class OEMHandler(object): @classmethod def is_event_log(cls, loginfo): """Say whether a log service holds events rather than something else""" - identity = '{0} {1}'.format(loginfo.get('Id', ''), - loginfo.get('Name', '')).lower() - for word in cls.noneventlogwords: - if word in identity: - return False + for part in (loginfo.get('Id', ''), loginfo.get('Name', '')): + # A build is free to space or punctuate the same name differently, + # "BIOS POST Code Log" for a post code log, so compare without any + # of that rather than miss it and merge 2719 post codes into the + # event log + flat = re.sub(r'[^a-z0-9]', '', str(part).lower()) + for word in cls.noneventlogwords: + if word in flat: + return False return True + async def _othereventlogcollections(self, fishclient, everywhere=True): + """The log service collections outside the manager. + + A platform is free to keep its event log on the system or the chassis + rather than on the manager, and more than one of those may link the very + same collection, so the answer is deduplicated. + """ + collections = [] + candidates = list(self._allsysurls) + if everywhere: + try: + chassiscol = await self._do_web_request('/redfish/v1/Chassis') + candidates.extend( + x['@odata.id'] for x in chassiscol.get('Members', [])) + except Exception: + # A platform that will not describe its chassis still has the + # rest of its logs to offer + pass + for url in candidates: + try: + info = await self._do_web_request(url) + except Exception: + continue + lscollection = info.get('LogServices', {}).get('@odata.id', None) + if lscollection and lscollection not in collections: + collections.append(lscollection) + return collections + async def get_event_log(self, clear=False, fishclient=None, extraurls=[]): bmcinfo = await self._do_web_request(await fishclient.get_bmcurl()) lsurl = bmcinfo.get('LogServices', {}).get('@odata.id', None) @@ -613,16 +645,21 @@ class OEMHandler(object): return found lurls = await eventlogurls(lsurl) - if not lurls: + if not lurls or not clear: # Some implementations keep no event log under the manager and put # it under the system instead, so fall back to looking there rather - # than answering with nothing at all. - for sysurl in self._allsysurls: - currsysinfo = await self._do_web_request(sysurl) - syslsurl = currsysinfo.get('LogServices', {}).get( - '@odata.id', None) - if syslsurl: - lurls.extend(await eventlogurls(syslsurl)) + # than answering with nothing at all. A read goes further and + # gathers every event log the platform publishes, since one that is + # never read is one nobody can act on. Clearing deliberately does + # not: a log that only a read reaches must not be destroyed by one. + for lscollection in await self._othereventlogcollections( + fishclient, everywhere=not clear): + if lscollection == lsurl: + # A platform may link the manager's own collection from the + # system and the chassis as well, and reading it three times + # would answer the same thing three times + continue + lurls.extend(await eventlogurls(lscollection)) lurls.extend([x['@odata.id'] for x in extraurls]) seenurls = set() for lurl in lurls: From 5caf0451fd2bf65ca2b50404193e0623ad2b8396 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 02:51:33 +0200 Subject: [PATCH 2/6] Let nodeeventlog show one log rather than all of them Every entry already says which log it came from, so -s narrows the output to the ones asked for and "-s list" names what a node offers. Some of what a platform keeps is noise: an AMI MegaRAC's event log is a list of redfish sessions being opened and closed, while its useful records are elsewhere. A selection cannot be cleared, since the platforms offer no such thing and clearing more than was asked for is not something to do quietly. --- confluent_client/bin/nodeeventlog | 52 +++++++++++++++++++++- confluent_client/doc/man/nodeeventlog.ronn | 26 ++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/confluent_client/bin/nodeeventlog b/confluent_client/bin/nodeeventlog index 80d64357..1587b6bd 100755 --- a/confluent_client/bin/nodeeventlog +++ b/confluent_client/bin/nodeeventlog @@ -53,7 +53,13 @@ argparser.add_option('-t', '--timeframe', type='string', 'entries from the last hours or days. ' '1h would be one hour, 4d would be four days. ' 'format h or d' - ) + ) + + +argparser.add_option('-s', '--source', type='string', + help='only show entries from the named logs, comma ' + 'delimited. "-s list" names the logs this node ' + 'offers instead of showing entries') (options, args) = argparser.parse_args() try: noderange = args[0] @@ -72,6 +78,19 @@ if len(args) == 2: argparser.print_help() sys.exit(1) +listmode = bool(options.source) and options.source.lower() == 'list' +wantsources = None +if options.source and not listmode: + wantsources = set(x.strip().lower() for x in options.source.split(',') + if x.strip()) +if options.source and deletemode: + # Clearing a subset is not something the platforms offer, and clearing more + # than was asked for is not something to do quietly + sys.stderr.write( + 'Clearing a selection of logs is not supported, so --source cannot be ' + 'combined with clear\n') + sys.exit(1) + session = client.Command() exitcode = 0 @@ -125,6 +144,7 @@ if options.timeframe: sys.exit(1) timeframe = dt.now() - tdelta +sources_seen = {} event_dict = {} nodes = [] for res in session.read('/noderange/{0}/nodes/'.format(args[0])): @@ -145,6 +165,17 @@ for rsp in func('/noderange/{0}/events/hardware/log'.format(noderange)): exitcode |= 1 if 'events' in thisdata: evtdata = thisdata['events'] + for evt in evtdata: + if evt.get('log_id', None): + sources_seen.setdefault(node, set()).add(evt['log_id']) + if listmode: + continue + if wantsources is not None: + # Filter before any tail is taken, or asking for the last + # few entries of one log would answer with fewer + evtdata = [x for x in evtdata if (x.get('log_id', '') + or '').lower() + in wantsources] if options.lines: event_dict[node].extend(evtdata) else: @@ -158,7 +189,24 @@ for rsp in func('/noderange/{0}/events/hardware/log'.format(noderange)): else: print('{0}: {1}'.format(node, format_event(evt))) -if options.lines: +if listmode: + for node in nodes: + found = sorted(sources_seen.get(node, ())) + if found: + print('{0}: {1}'.format(node, ','.join(found))) + else: + sys.stderr.write( + 'No event log sources reported for "{0}"\n'.format(node)) +elif wantsources is not None: + for node in nodes: + found = set(x.lower() for x in sources_seen.get(node, ())) + missing = wantsources - found + if missing and found: + sys.stderr.write('{0}: no such log source: {1}, this node has {2}\n' + .format(node, ','.join(sorted(missing)), + ','.join(sorted(sources_seen[node])))) + +if options.lines and not listmode: for node in nodes: evtdata_list = event_dict[node] if len(evtdata_list) != 0: diff --git a/confluent_client/doc/man/nodeeventlog.ronn b/confluent_client/doc/man/nodeeventlog.ronn index 521883af..17cb4404 100644 --- a/confluent_client/doc/man/nodeeventlog.ronn +++ b/confluent_client/doc/man/nodeeventlog.ronn @@ -3,13 +3,23 @@ nodeeventlog(8) -- Pull eventlog from confluent nodes ## SYNOPSIS -`nodeeventlog [options] [clear]` +`nodeeventlog [options] [-s ] [clear]` ## DESCRIPTION `nodeeventlog` pulls and optionally clears the event log from the requested noderange. +A platform may keep its events in more than one log, and some of those are +noisier than others. Reading gathers every log the platform describes as an +event log, wherever it keeps them, and `-s` narrows the output to the ones +asked for. `-s list` names the logs a node offers rather than showing entries. + +Clearing is deliberately narrower than reading: it only touches the logs the +platform's own manager publishes, so a log that only a read reaches is never +destroyed by one. A selection cannot be cleared, so `-s` and `clear` are +refused together. + ## OPTIONS * `-m MAXNODES`, `--maxnodes=MAXNODES`: @@ -25,10 +35,24 @@ noderange. entries from within the last one hour. +* `-s SOURCES`, `--source=SOURCES`: + only show entries from the named logs, comma delimited and matched without + regard to case. `-s list` names the logs the node offers instead of showing + entries. A platform that named a log "list" would be shadowed by that. + * `-h`, `--help`: Show help message and exit ## EXAMPLES +* Ask which logs a node keeps: + `# nodeeventlog n2 -s list` + `n2: AuditLog,EventLog,Logs,SEL` + +* Pull only the sel and the chassis log, leaving the audit noise out: + `# nodeeventlog n2 -s SEL,Logs` + `n2: 07/23/2026 13:03:12 SEL: Processor - Thermal Trip` + `n2: 08/05/2026 04:30:26 Logs: PowerUnit - Power Off` + * Pull the event log from n2 and n3: `# nodeeventlog n2,n3` `n2: 05/03/2017 11:44:25 Event Log Disabled - SEL Fullness - Log clear` From 43d0706d6a197c18062099d8277d2da74b8624fc Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 03:39:59 +0200 Subject: [PATCH 3/6] Drop trailing whitespace from the synopsis line --- confluent_client/doc/man/nodeeventlog.ronn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/confluent_client/doc/man/nodeeventlog.ronn b/confluent_client/doc/man/nodeeventlog.ronn index 17cb4404..a4d357cb 100644 --- a/confluent_client/doc/man/nodeeventlog.ronn +++ b/confluent_client/doc/man/nodeeventlog.ronn @@ -3,7 +3,7 @@ nodeeventlog(8) -- Pull eventlog from confluent nodes ## SYNOPSIS -`nodeeventlog [options] [-s ] [clear]` +`nodeeventlog [options] [-s ] [clear]` ## DESCRIPTION From 9cdcbe4046454fcfc9b02fd0cb3dbb2d05df900d Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 13:00:10 +0200 Subject: [PATCH 4/6] Look elsewhere when the manager publishes no log services The early return sat before the fallback, so the one layout it was written for was the one it could not reach. --- confluent_server/aiohmi/redfish/oem/generic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/confluent_server/aiohmi/redfish/oem/generic.py b/confluent_server/aiohmi/redfish/oem/generic.py index c5cd799e..b44bc253 100644 --- a/confluent_server/aiohmi/redfish/oem/generic.py +++ b/confluent_server/aiohmi/redfish/oem/generic.py @@ -611,9 +611,10 @@ class OEMHandler(object): async def get_event_log(self, clear=False, fishclient=None, extraurls=[]): bmcinfo = await self._do_web_request(await fishclient.get_bmcurl()) + # A manager need not publish log services at all, and one that does not + # is the clearest case of a platform keeping its event log elsewhere, so + # carry on to the fallback below rather than answering with nothing lsurl = bmcinfo.get('LogServices', {}).get('@odata.id', None) - if not lsurl: - return currtime = bmcinfo.get('DateTime', None) correction = timedelta(0) utz = tz.tzoffset('', 0) @@ -644,7 +645,7 @@ class OEMHandler(object): found.append(candidate) return found - lurls = await eventlogurls(lsurl) + lurls = await eventlogurls(lsurl) if lsurl else [] if not lurls or not clear: # Some implementations keep no event log under the manager and put # it under the system instead, so fall back to looking there rather From c13a43424694051b0b00171c75b5d82e6665fa07 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 13:00:52 +0200 Subject: [PATCH 5/6] Say when a named log source answered with nothing An ipmi node named no source at all, so -s dropped every event, and a log with no entries can never be named by one. --- confluent_client/bin/nodeeventlog | 12 +++++++++++- confluent_client/doc/man/nodeeventlog.ronn | 7 +++++++ .../confluent/plugins/hardwaremanagement/ipmi.py | 4 ++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/confluent_client/bin/nodeeventlog b/confluent_client/bin/nodeeventlog index 1587b6bd..6d35ed57 100755 --- a/confluent_client/bin/nodeeventlog +++ b/confluent_client/bin/nodeeventlog @@ -201,10 +201,20 @@ elif wantsources is not None: for node in nodes: found = set(x.lower() for x in sources_seen.get(node, ())) missing = wantsources - found - if missing and found: + if not missing: + continue + if found: sys.stderr.write('{0}: no such log source: {1}, this node has {2}\n' .format(node, ','.join(sorted(missing)), ','.join(sorted(sources_seen[node])))) + else: + # A log that holds no entries names no source, so an empty answer + # here is as often a quiet platform as a wrong name. Either way + # saying nothing would look like the log simply had nothing in it. + sys.stderr.write( + '{0}: no entries from any log source, so nothing could match ' + '{1}\n'.format(node, ','.join(sorted(missing)))) + exitcode |= 1 if options.lines and not listmode: for node in nodes: diff --git a/confluent_client/doc/man/nodeeventlog.ronn b/confluent_client/doc/man/nodeeventlog.ronn index a4d357cb..abce6d84 100644 --- a/confluent_client/doc/man/nodeeventlog.ronn +++ b/confluent_client/doc/man/nodeeventlog.ronn @@ -15,6 +15,11 @@ noisier than others. Reading gathers every log the platform describes as an event log, wherever it keeps them, and `-s` narrows the output to the ones asked for. `-s list` names the logs a node offers rather than showing entries. +A source is named by the entries that come from it, so a log that holds no +entries is not among them, and a node whose logs are all empty names none. An +ipmi managed node keeps its events in the one log the spec gives it, which is +reported as `SEL`. + Clearing is deliberately narrower than reading: it only touches the logs the platform's own manager publishes, so a log that only a read reaches is never destroyed by one. A selection cannot be cleared, so `-s` and `clear` are @@ -39,6 +44,8 @@ refused together. only show entries from the named logs, comma delimited and matched without regard to case. `-s list` names the logs the node offers instead of showing entries. A platform that named a log "list" would be shadowed by that. + Asking for a log the node named nothing from is reported on stderr and + exits non-zero, rather than looking like an empty log. * `-h`, `--help`: Show help message and exit diff --git a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py index 2e832978..859eda28 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py @@ -933,6 +933,10 @@ class IpmiHandler: await self.output.put(msg.EventCollection(eventout, name=self.node)) def pyghmi_event_to_confluent(self, event): + # An ipmi platform keeps its events in one place and the spec has a + # name for it, so say so rather than leaving the source blank and + # having a caller asking for a named log get nothing back + event.setdefault('log_id', 'SEL') event['severity'] = _str_health(event.get('severity', 'unknown')) if 'event_data' in event: event['event'] = '{0} - {1}'.format( From 57b3fae0ed2ed805f0b35f34dc6454a571eb2b4b Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sat, 15 Aug 2026 13:46:59 +0200 Subject: [PATCH 6/6] Return the status nodeeventlog worked out Every error branch set exitcode and the script then ran off the end, so a node that could not be read, or a log source that matched nothing, still exited 0. --- confluent_client/bin/nodeeventlog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/confluent_client/bin/nodeeventlog b/confluent_client/bin/nodeeventlog index 6d35ed57..c071b69a 100755 --- a/confluent_client/bin/nodeeventlog +++ b/confluent_client/bin/nodeeventlog @@ -230,3 +230,5 @@ if options.lines and not listmode: print('{0}: {1}'.format(node, format_event(evt))) else: print('{0}: {1}'.format(node, format_event(evt))) + +sys.exit(exitcode)