Redfish: Support Host Log Entries
This commit is designing and implementing a new logging service in
Redfish to expose host serial console logs.The goal is that clients
can talk to bmc via Redfish and get a real-time console. It will improve
the debuggability of BMCs.
We will add three paths to redfish tree to implement the feature:
1. /redfish/v1/Systems/system/LogServices/HostLogger
2. /redfish/v1/Systems/system/LogServices/HostLogger/Entries
3. /redfish/v1/Systems/system/LogServices/HostLogger/Entries/<str>
To use this feature, we expect to use phosphor-hostlogger(stream mode)
+ rsyslog + bmcweb. Phosphor-hostlooger in stream mode forwards the byte
stream into rsyslog via the imuxsock module. The log is persisted via
the omfile module as soon as collected. It makes Host Logger leverage
exsisting tools (rsyslog and logrotate). Then we can expose host serial
console logs via bmcweb.
This feature can be enabled or disabled by setting the option
"redfish-host-logger", and the default value is "enabled". If you don't
want to expose host serial console logs, you need to turn the value to
"disabled".
RedfishServiceValidator results:
/redfish/v1/Systems/system/LogServices/HostLogger
pass: 4
passGet: 1
skipOptional: 9
/redfish/v1/Systems/system/LogServices/HostLogger/Entries
pass: 24
passGet: 1
skipOptional: 1
/redfish/v1/Systems/system/LogServices/HostLogger/Entries/<str>
pass: 7
passGet: 1
skipOptional: 15
Sample Output:
curl -k -H "X-Auth-Token: $token" -X GET https://${bmc}/redfish/v1/Systems/system/LogServices/HostLogger/Entries
{
"@odata.id": "/redfish/v1/Systems/system/LogServices/HostLogger/Entries",
"@odata.type": "#LogEntryCollection.LogEntryCollection",
"Description": "Collection of HostLogger Entries",
"Members": [
{
"@odata.id": "/redfish/v1/Systems/system/LogServices/HostLogger/Entries/1",
"@odata.type": "#LogEntry.v1_4_0.LogEntry",
"EntryType": "Event",
"Id": "1",
"Message": "[ 57.061546] gq 0000:16:00.0 eth0: link up, 100 Gbps, no PAUSE",
"MessageArgs": [
"[ 57.061546] gq 0000:16:00.0 eth0: link up, 100 Gbps, no PAUSE"
],
"MessageId": "OpenBMC.0.1.SerialLogAdded",
"Name": "HostLogger Entries",
"Severity": "OK"
},
...
],
"Members@odata.count": 22,
"Name": "HostLogger Entries"
}
Signed-off-by: SpencerKu <Spencer.Ku@quantatw.com>
Change-Id: I5a7873caa117400fb0a737588a50bd743e8b5063
diff --git a/meson.build b/meson.build
index 7673112..0bcf366 100644
--- a/meson.build
+++ b/meson.build
@@ -77,6 +77,7 @@
'redfish' : '-DBMCWEB_ENABLE_REDFISH',
'redfish-bmc-journal' : '-DBMCWEB_ENABLE_REDFISH_BMC_JOURNAL',
'redfish-cpu-log' : '-DBMCWEB_ENABLE_REDFISH_CPU_LOG',
+ 'redfish-host-logger' : '-DBMCWEB_ENABLE_REDFISH_HOST_LOGGER',
'redfish-dbus-log' : '-DBMCWEB_ENABLE_REDFISH_DBUS_LOG_ENTRIES',
'redfish-provisioning-feature' : '-DBMCWEB_ENABLE_REDFISH_PROVISIONING_FEATURE',
'redfish-dump-log' : '-DBMCWEB_ENABLE_REDFISH_DUMP_LOG',
diff --git a/meson_options.txt b/meson_options.txt
index ada1957..6c0f643 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -19,6 +19,7 @@
option('redfish-cpu-log', type : 'feature', value : 'disabled', description : '''Enable CPU log service transactions through Redfish. Paths are under \'/redfish/v1/Systems/system/LogServices/Crashdump'.''')
option('redfish-dump-log', type : 'feature', value : 'disabled', description : 'Enable Dump log service transactions through Redfish. Paths are under \'/redfish/v1/Systems/system/LogServices/Dump\'and \'/redfish/v1/Managers/bmc/LogServices/Dump\'')
option('redfish-dbus-log', type : 'feature', value : 'disabled', description : 'Enable DBUS log service transactions through Redfish. Paths are under \'/redfish/v1/Systems/system/LogServices/EventLog/Entries\'')
+option('redfish-host-logger', type : 'feature', value : 'enabled', description : 'Enable host log service transactions based on phosphor-hostlogger through Redfish. Paths are under \'/redfish/v1/Systems/system/LogServices/HostLogger\'')
option('redfish-provisioning-feature', type : 'feature', value : 'disabled', description : 'Enable provisioning feature support in redfish. Paths are under \'/redfish/v1/Systems/system/\'')
option('bmcweb-logging', type : 'feature', value : 'disabled', description : 'Enable output the extended debug logs')
option('basic-auth', type : 'feature', value : 'enabled', description : '''Enable basic authentication''')
diff --git a/redfish-core/include/gzfile.hpp b/redfish-core/include/gzfile.hpp
new file mode 100644
index 0000000..9f1b5db
--- /dev/null
+++ b/redfish-core/include/gzfile.hpp
@@ -0,0 +1,178 @@
+#pragma once
+
+#include <zlib.h>
+
+#include <array>
+#include <filesystem>
+#include <vector>
+
+class GzFileReader
+{
+ public:
+ bool gzGetLines(std::vector<std::string>& logEntries,
+ const std::string& filename)
+ {
+ gzFile logStream = gzopen(filename.c_str(), "r");
+ if (!logStream)
+ {
+ BMCWEB_LOG_ERROR << "Can't open gz file: " << filename << '\n';
+ return false;
+ }
+
+ std::string wholeFile;
+ if (!readFile(wholeFile, logStream))
+ {
+ gzclose(logStream);
+ return false;
+ }
+ std::string newLastMessage;
+ std::vector<std::string> parseLogs =
+ hostLogEntryParser(wholeFile, newLastMessage);
+
+ // If file doesn't contain any "\r" or "\n", parseLogs should be empty.
+ if (parseLogs.empty())
+ {
+ lastMessage.insert(lastMessage.end(),
+ std::make_move_iterator(newLastMessage.begin()),
+ std::make_move_iterator(newLastMessage.end()));
+ }
+ else
+ {
+ if (!lastMessage.empty())
+ {
+ parseLogs.front() = lastMessage + parseLogs.front();
+ lastMessage.clear();
+ }
+ if (!newLastMessage.empty())
+ {
+ lastMessage = std::move(newLastMessage);
+ }
+ logEntries.insert(logEntries.end(),
+ std::make_move_iterator(parseLogs.begin()),
+ std::make_move_iterator(parseLogs.end()));
+ }
+ gzclose(logStream);
+ return true;
+ }
+
+ std::string getLastMessage()
+ {
+ return lastMessage;
+ }
+
+ private:
+ std::string lastMessage;
+ std::string lastDelimiter;
+ size_t totalFilesSize = 0;
+
+ void printErrorMessage(gzFile logStream)
+ {
+ int errNum = 0;
+ const char* errMsg = gzerror(logStream, &errNum);
+
+ BMCWEB_LOG_ERROR << "Error reading gz compressed data.\n"
+ << "Error Message: " << errMsg << '\n'
+ << "Error Number: " << errNum;
+ }
+
+ bool readFile(std::string& wholeFile, gzFile logStream)
+ {
+ // Assume we have 8 files, and the max size of each file is
+ // 16k, so define the max size as 256kb (double of 8 files *
+ // 16kb)
+ constexpr size_t maxTotalFilesSize = 262144;
+ constexpr int bufferLimitSize = 1024;
+ do
+ {
+ std::string bufferStr;
+ bufferStr.resize(bufferLimitSize);
+
+ int bytesRead = gzread(logStream, bufferStr.data(),
+ static_cast<unsigned int>(bufferStr.size()));
+ // On errors, gzread() shall return a value less than 0.
+ if (bytesRead < 0)
+ {
+ printErrorMessage(logStream);
+ return false;
+ }
+ bufferStr.resize(static_cast<size_t>(bytesRead));
+ totalFilesSize += bufferStr.size();
+ if (totalFilesSize > maxTotalFilesSize)
+ {
+ BMCWEB_LOG_ERROR << "File size exceeds maximum allowed size of "
+ << maxTotalFilesSize;
+ return false;
+ }
+ wholeFile.insert(wholeFile.end(),
+ std::make_move_iterator(bufferStr.begin()),
+ std::make_move_iterator(bufferStr.end()));
+ } while (!gzeof(logStream));
+
+ return true;
+ }
+
+ std::vector<std::string> hostLogEntryParser(const std::string& wholeFile,
+ std::string& newLastMessage)
+ {
+ std::vector<std::string> logEntries;
+
+ // It may contain several log entry in one line, and
+ // the end of each log entry will be '\r\n' or '\r'.
+ // So we need to go through and split string by '\n' and '\r'
+ size_t pos = wholeFile.find_first_of("\n\r");
+ size_t initialPos = 0;
+
+ while (pos != std::string::npos)
+ {
+ std::string logEntry =
+ wholeFile.substr(initialPos, pos - initialPos);
+ // Since there might be consecutive delimiters like "\r\n", we need
+ // to filter empty strings.
+ if (!logEntry.empty())
+ {
+ logEntries.push_back(logEntry);
+ }
+ else
+ {
+ // Handle consecutive delimiter. '\r\n' act as a single
+ // delimiter, the other case like '\n\n', '\n\r' or '\r\r' will
+ // push back a "\n" as a log.
+ std::string delimiters;
+ if (pos > 0)
+ {
+ delimiters = wholeFile.substr(pos - 1, 2);
+ }
+ // Handle consecutive delimiter but spilt between two files.
+ if (pos == 0 && !(lastDelimiter.empty()))
+ {
+ delimiters = lastDelimiter + wholeFile.substr(0, 1);
+ }
+ if (delimiters != "\r\n")
+ {
+ logEntries.emplace_back("\n");
+ }
+ }
+ initialPos = pos + 1;
+ pos = wholeFile.find_first_of("\n\r", initialPos);
+ }
+
+ // Store the last message
+ if (initialPos < wholeFile.size())
+ {
+ newLastMessage = wholeFile.substr(initialPos);
+ }
+ // If consecutive delimiter spilt by file, the last character of the
+ // file must be the delimiter.
+ else if (initialPos == wholeFile.size())
+ {
+ lastDelimiter = wholeFile.substr(initialPos - 1, 1);
+ }
+ return logEntries;
+ }
+
+ public:
+ GzFileReader() = default;
+ ~GzFileReader() = default;
+ GzFileReader(const GzFileReader&) = delete;
+ GzFileReader& operator=(const GzFileReader&) = delete;
+};
diff --git a/redfish-core/include/redfish.hpp b/redfish-core/include/redfish.hpp
index 0a97150..2479332 100644
--- a/redfish-core/include/redfish.hpp
+++ b/redfish-core/include/redfish.hpp
@@ -161,6 +161,12 @@
requestRoutesDBusEventLogEntryDownload(app);
#endif
+#ifdef BMCWEB_ENABLE_REDFISH_HOST_LOGGER
+ requestRoutesSystemHostLogger(app);
+ requestRoutesSystemHostLoggerCollection(app);
+ requestRoutesSystemHostLoggerLogEntry(app);
+#endif
+
requestRoutesMessageRegistryFileCollection(app);
requestRoutesMessageRegistryFile(app);
requestRoutesMessageRegistry(app);
diff --git a/redfish-core/lib/log_services.hpp b/redfish-core/lib/log_services.hpp
index 2c9ae3a..065a4d1 100644
--- a/redfish-core/lib/log_services.hpp
+++ b/redfish-core/lib/log_services.hpp
@@ -15,7 +15,9 @@
*/
#pragma once
+#include "gzfile.hpp"
#include "http_utility.hpp"
+#include "human_sort.hpp"
#include "registries.hpp"
#include "registries/base_message_registry.hpp"
#include "registries/openbmc_message_registry.hpp"
@@ -975,6 +977,12 @@
{{"@odata.id",
"/redfish/v1/Systems/system/LogServices/Crashdump"}});
#endif
+
+#ifdef BMCWEB_ENABLE_REDFISH_HOST_LOGGER
+ logServiceArray.push_back(
+ {{"@odata.id",
+ "/redfish/v1/Systems/system/LogServices/HostLogger"}});
+#endif
asyncResp->res.jsonValue["Members@odata.count"] =
logServiceArray.size();
@@ -1817,6 +1825,221 @@
});
}
+constexpr const char* hostLoggerFolderPath = "/var/log/console";
+inline bool
+ getHostLoggerFiles(const std::string& hostLoggerFilePath,
+ std::vector<std::filesystem::path>& hostLoggerFiles)
+{
+ std::error_code ec;
+ std::filesystem::directory_iterator logPath(hostLoggerFilePath, ec);
+ if (ec)
+ {
+ BMCWEB_LOG_ERROR << ec.message();
+ return false;
+ }
+ for (const std::filesystem::directory_entry& it : logPath)
+ {
+ std::string filename = it.path().filename();
+ // Prefix of each log files is "log". Find the file and save the
+ // path
+ if (boost::starts_with(filename, "log"))
+ {
+ hostLoggerFiles.emplace_back(it.path());
+ }
+ }
+ // As the log files rotate, they are appended with a ".#" that is higher for
+ // the older logs. Since we start from oldest logs, sort the name in
+ // descending order.
+ std::sort(hostLoggerFiles.rbegin(), hostLoggerFiles.rend(),
+ AlphanumLess<std::string>());
+
+ return true;
+}
+
+inline bool
+ getHostLoggerEntries(std::vector<std::filesystem::path>& hostLoggerFiles,
+ std::vector<std::string>& logEntries)
+{
+ GzFileReader logFile;
+
+ // Go though all log files and expose host log into logEntries
+ for (const std::filesystem::path& it : hostLoggerFiles)
+ {
+ if (!logFile.gzGetLines(logEntries, it.string()))
+ {
+ BMCWEB_LOG_ERROR << "fail to expose host logs";
+ return false;
+ }
+ }
+ // Get lastMessage from constructor by getter
+ std::string lastMessage = logFile.getLastMessage();
+ if (!lastMessage.empty())
+ {
+ logEntries.push_back(lastMessage);
+ }
+ return true;
+}
+
+inline void fillHostLoggerEntryJson(const std::string& logEntryID,
+ const std::string& msg,
+ nlohmann::json& logEntryJson)
+{
+ // Fill in the log entry with the gathered data.
+ logEntryJson = {
+ {"@odata.type", "#LogEntry.v1_4_0.LogEntry"},
+ {"@odata.id",
+ "/redfish/v1/Systems/system/LogServices/HostLogger/Entries/" +
+ logEntryID},
+ {"Name", "Host Logger Entry"},
+ {"Id", logEntryID},
+ {"Message", msg},
+ {"EntryType", "Oem"},
+ {"Severity", "OK"},
+ {"OemRecordFormat", "Host Logger Entry"}};
+}
+
+inline void requestRoutesSystemHostLogger(App& app)
+{
+ BMCWEB_ROUTE(app, "/redfish/v1/Systems/system/LogServices/HostLogger/")
+ .privileges(redfish::privileges::getLogService)
+ .methods(boost::beast::http::verb::get)(
+ [](const crow::Request&,
+ const std::shared_ptr<bmcweb::AsyncResp>& asyncResp) {
+ asyncResp->res.jsonValue["@odata.id"] =
+ "/redfish/v1/Systems/system/LogServices/HostLogger";
+ asyncResp->res.jsonValue["@odata.type"] =
+ "#LogService.v1_1_0.LogService";
+ asyncResp->res.jsonValue["Name"] = "Host Logger Service";
+ asyncResp->res.jsonValue["Description"] = "Host Logger Service";
+ asyncResp->res.jsonValue["Id"] = "HostLogger";
+ asyncResp->res.jsonValue["Entries"] = {
+ {"@odata.id", "/redfish/v1/Systems/system/LogServices/"
+ "HostLogger/Entries"}};
+ });
+}
+
+inline void requestRoutesSystemHostLoggerCollection(App& app)
+{
+ BMCWEB_ROUTE(app,
+ "/redfish/v1/Systems/system/LogServices/HostLogger/Entries/")
+ .privileges(redfish::privileges::getLogEntry)
+ .methods(boost::beast::http::verb::get)(
+ [](const crow::Request& req,
+ const std::shared_ptr<bmcweb::AsyncResp>& asyncResp) {
+ uint64_t skip = 0;
+ uint64_t top = maxEntriesPerPage; // Show max 1000 entries by
+ // default, allow range 1 to
+ // 1000 entries per page.
+ if (!getSkipParam(asyncResp, req, skip))
+ {
+ return;
+ }
+ if (!getTopParam(asyncResp, req, top))
+ {
+ return;
+ }
+ asyncResp->res.jsonValue["@odata.id"] =
+ "/redfish/v1/Systems/system/LogServices/HostLogger/Entries";
+ asyncResp->res.jsonValue["@odata.type"] =
+ "#LogEntryCollection.LogEntryCollection";
+ asyncResp->res.jsonValue["Name"] = "HostLogger Entries";
+ asyncResp->res.jsonValue["Description"] =
+ "Collection of HostLogger Entries";
+ nlohmann::json& logEntryArray =
+ asyncResp->res.jsonValue["Members"];
+ logEntryArray = nlohmann::json::array();
+
+ std::vector<std::filesystem::path> hostLoggerFiles;
+ if (!getHostLoggerFiles(hostLoggerFolderPath, hostLoggerFiles))
+ {
+ BMCWEB_LOG_ERROR << "fail to get host log file path";
+ messages::internalError(asyncResp->res);
+ return;
+ }
+
+ std::vector<std::string> logEntries;
+ if (!getHostLoggerEntries(hostLoggerFiles, logEntries))
+ {
+ messages::internalError(asyncResp->res);
+ return;
+ }
+
+ for (uint64_t id = skip;
+ id < std::min<uint64_t>(skip + top, logEntries.size());
+ id++)
+ {
+ logEntryArray.push_back({});
+ nlohmann::json& hostLogEntry = logEntryArray.back();
+ fillHostLoggerEntryJson(std::to_string(id),
+ logEntries[static_cast<size_t>(id)],
+ hostLogEntry);
+ }
+
+ asyncResp->res.jsonValue["Members@odata.count"] =
+ logEntries.size();
+ if (skip + top < logEntries.size())
+ {
+ asyncResp->res.jsonValue["Members@odata.nextLink"] =
+ "/redfish/v1/Systems/system/LogServices/HostLogger/"
+ "Entries?skip=" +
+ std::to_string(skip + top);
+ }
+ });
+}
+
+inline void requestRoutesSystemHostLoggerLogEntry(App& app)
+{
+ BMCWEB_ROUTE(
+ app, "/redfish/v1/Systems/system/LogServices/HostLogger/Entries/<str>/")
+ .privileges(redfish::privileges::getLogEntry)
+ .methods(boost::beast::http::verb::get)(
+ [](const crow::Request&,
+ const std::shared_ptr<bmcweb::AsyncResp>& asyncResp,
+ const std::string& param) {
+ const std::string& targetID = param;
+
+ std::vector<std::filesystem::path> hostLoggerFiles;
+ if (!getHostLoggerFiles(hostLoggerFolderPath, hostLoggerFiles))
+ {
+ BMCWEB_LOG_ERROR << "fail to get host log file path";
+ messages::internalError(asyncResp->res);
+ return;
+ }
+
+ std::vector<std::string> logEntries;
+ if (!getHostLoggerEntries(hostLoggerFiles, logEntries))
+ {
+ messages::internalError(asyncResp->res);
+ return;
+ }
+
+ uint64_t idInt = 0;
+ auto [ptr, ec] = std::from_chars(
+ targetID.data(), targetID.data() + targetID.size(), idInt);
+ if (ec == std::errc::invalid_argument)
+ {
+ messages::resourceMissingAtURI(asyncResp->res, targetID);
+ return;
+ }
+ if (ec == std::errc::result_out_of_range)
+ {
+ messages::resourceMissingAtURI(asyncResp->res, targetID);
+ return;
+ }
+
+ if (idInt < logEntries.size())
+ {
+ fillHostLoggerEntryJson(
+ targetID, logEntries[static_cast<size_t>(idInt)],
+ asyncResp->res.jsonValue);
+ return;
+ }
+
+ // Requested ID was not found
+ messages::resourceMissingAtURI(asyncResp->res, targetID);
+ });
+}
+
inline void requestRoutesBMCLogServiceCollection(App& app)
{
BMCWEB_ROUTE(app, "/redfish/v1/Managers/bmc/LogServices/")