diff --git a/confluent_client/bin/nodeeventlog b/confluent_client/bin/nodeeventlog index 80d64357..c071b69a 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,34 @@ 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 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: evtdata_list = event_dict[node] if len(evtdata_list) != 0: @@ -172,3 +230,5 @@ if options.lines: print('{0}: {1}'.format(node, format_event(evt))) else: print('{0}: {1}'.format(node, format_event(evt))) + +sys.exit(exitcode) diff --git a/confluent_client/doc/man/nodeeventlog.ronn b/confluent_client/doc/man/nodeeventlog.ronn index 521883af..abce6d84 100644 --- a/confluent_client/doc/man/nodeeventlog.ronn +++ b/confluent_client/doc/man/nodeeventlog.ronn @@ -3,13 +3,28 @@ 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. + +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 +refused together. + ## OPTIONS * `-m MAXNODES`, `--maxnodes=MAXNODES`: @@ -25,10 +40,26 @@ 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. + 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 ## 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` diff --git a/confluent_server/aiohmi/redfish/oem/generic.py b/confluent_server/aiohmi/redfish/oem/generic.py index 7ce2f84d..b44bc253 100644 --- a/confluent_server/aiohmi/redfish/oem/generic.py +++ b/confluent_server/aiohmi/redfish/oem/generic.py @@ -570,18 +570,51 @@ 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()) + # 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) @@ -612,17 +645,22 @@ class OEMHandler(object): found.append(candidate) return found - lurls = await eventlogurls(lsurl) - if not lurls: + 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 - # 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: 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(