From b52507932c94c9c89753689307c9a00cc003ee64 Mon Sep 17 00:00:00 2001 From: Matthew Hilton Date: Thu, 12 Oct 2023 15:33:37 +1000 Subject: [PATCH] [#151] Add new sitecheck page --- classes/check/tasklatencycheck.php | 6 +- classes/checker.php | 194 +++++++++++++++++++++++++++++ classes/resultmessage.php | 49 ++++++++ sitecheck.php | 86 +++++++++++++ templates/resultmessage.mustache | 40 ++++++ tests/checker_test.php | 140 +++++++++++++++++++++ 6 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 classes/checker.php create mode 100644 classes/resultmessage.php create mode 100644 sitecheck.php create mode 100644 templates/resultmessage.mustache create mode 100644 tests/checker_test.php diff --git a/classes/check/tasklatencycheck.php b/classes/check/tasklatencycheck.php index 77d2966..4e740fe 100644 --- a/classes/check/tasklatencycheck.php +++ b/classes/check/tasklatencycheck.php @@ -77,9 +77,9 @@ public function get_result(): result { $valid = $task !== false; // Cast to int, will force non-int strings to 0, so we only need to care about negative time as invalid. - $valid &= ((int) $runtime >= 0); - $valid &= ((int) $startdelay >= 0); - $valid &= ((int) $completiondelay >= 0); + $valid &= ((int) $runtime >= 0); // 30 + $valid &= ((int) $startdelay >= 0); // 6 + $valid &= ((int) $completiondelay >= 0); // 45 if (!$valid) { return new result(result::ERROR, get_string('taskconfigbad', 'tool_heartbeat', $taskclass)); diff --git a/classes/checker.php b/classes/checker.php new file mode 100644 index 0000000..d241c15 --- /dev/null +++ b/classes/checker.php @@ -0,0 +1,194 @@ +. + +namespace tool_heartbeat; + +use core\check\check; +use core\check\result; +use Throwable; + +/** + * Check API checker class + * + * Processes check API results and returns them in a nice format for nagios output. + * + * @package tool_heartbeat + * @author Matthew Hilton + * @copyright 2023, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class checker { + /** @var array Nagios level prefixes **/ + public const NAGIOS_PREFIXES = [ + 0 => "OK", + 1 => "WARNING", + 2 => "CRITICAL", + 3 => "UNKNOWN", + ]; + + /** + * Returns an array of check API messages. + * If exceptions are thrown, they are caught and returned as result messages as well. + * + * @return array array of resultmessage objects + */ + public static function get_check_messages(): array { + // First try to get the checks, if this fails return a critical message (code is very broken). + $checks = []; + + try { + $checks = \core\check\manager::get_checks('status'); + } catch (Throwable $e) { + return [self::exception_to_message("Error getting checks: ", $e)]; + } + + // Execute each check and store their messages. + $messages = []; + + foreach ($checks as $check) { + try { + $messages[] = self::process_check_and_get_result($check); + } catch (Throwable $e) { + $messages[] = self::exception_to_message("Error processing check " . $check->get_ref() . ": ", $e); + } + } + + return $messages; + } + + /** + * Turns the given exception into a warning resultmessage. + * @param string $prefix + * @param Throwable $e + * @return resultmessage + */ + private static function exception_to_message(string $prefix, Throwable $e): resultmessage { + $res = new resultmessage(); + $res->level = resultmessage::LEVEL_WARN; + $res->title = $prefix . $e->getMessage(); + $res->message = (string) $e; + return $res; + } + + /** + * Processes the check and maps its result and status to a resultmessage. + * @param check $check + * @return resultmessage + */ + private static function process_check_and_get_result(check $check): resultmessage { + $res = new resultmessage(); + + $checkresult = $check->get_result(); + + // Map check result to nagios level. + $map = [ + result::WARNING => resultmessage::LEVEL_WARN, + result::CRITICAL => resultmessage::LEVEL_CRITICAL, + result::OK => resultmessage::LEVEL_OK, + result::NA => resultmessage::LEVEL_OK, + result::WARNING => resultmessage::LEVEL_WARN, + result::UNKNOWN => resultmessage::LEVEL_UNKNOWN, + result::ERROR => resultmessage::LEVEL_CRITICAL, + ]; + + // Get the level, or default to unknown. + $status = $checkresult->get_status(); + $res->level = isset($map[$status]) ? $map[$status] : resultmessage::LEVEL_UNKNOWN; + + $res->title = $check->get_name(); + + // Add the first line of summary to the title. + $summarylines = explode("\n", $checkresult->get_summary()); + $res->title .= ": " . $summarylines[0] ?: ''; + + $res->message = $checkresult->get_summary(); + + return $res; + } + + /** + * From an array of resultmessage, determines the highest nagios level. + * Note, it considers UNKNOWN to be less than CRITICAL or WARNING. + * + * @param array $messages array of resultmessage objects + * @return int the calculated nagios level + */ + public static function determine_nagios_level(array $messages): int { + // Find the highest level. + $levels = array_column($messages, "level"); + + // Add a default "OK" in case no messages were returned. + $levels[] = resultmessage::LEVEL_OK; + + $hasunknown = !empty(array_filter($levels, function($l) { + return $l == resultmessage::LEVEL_UNKNOWN; + })); + + // Remove unknowns. + $levels = array_filter($levels, function($l) { + return $l != resultmessage::LEVEL_UNKNOWN; + }); + + $highest = max($levels); + + // If highest was OK but it had an unknown, return unknown. + // This stops UNKNOWN from masking WARNING or CRITICAL. + if ($highest == resultmessage::LEVEL_OK && $hasunknown) { + return resultmessage::LEVEL_UNKNOWN; + } + + // Else return OK. + return $highest; + } + + /** + * Creates a summary from the given messages. + * If there are no messages or only OK, OK is returned. + * If there is a single message, its details are returned. + * If there are multiple messages, the levels are aggregated and turned into a summary. + * + * @param array $messages array of resultmessage objects + * @return string + */ + public static function create_summary(array $messages): string { + // Filter out any OK messages. + $messages = array_filter($messages, function($m) { + return $m->level != resultmessage::LEVEL_OK; + }); + + // If no messages, return OK. + if (count($messages) == 0) { + return "OK"; + } + + // If only one message, use it as the top level. + if (count($messages) == 1) { + return $messages[0]->title; + } + + // Otherwise count how many of each level. + $counts = array_count_values(array_column($messages, 'level')); + + $countswithprefixes = []; + foreach ($counts as $level => $occurrences) { + $prefix = self::NAGIOS_PREFIXES[$level]; + $countswithprefixes[] = "{$occurrences} {$prefix}"; + } + + return "Multiple problems detected: " . implode(", ", $countswithprefixes); + } +} + diff --git a/classes/resultmessage.php b/classes/resultmessage.php new file mode 100644 index 0000000..7de5958 --- /dev/null +++ b/classes/resultmessage.php @@ -0,0 +1,49 @@ +. + +namespace tool_heartbeat; + +/** + * A data-only class for holding a message about a result from a check API class. + * + * @package tool_heartbeat + * @author Matthew Hilton + * @copyright 2023, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class resultmessage { + /** @var int OK level **/ + public const LEVEL_OK = 0; + + /** @var int WARN level **/ + public const LEVEL_WARN = 1; + + /** @var int CRITICAL level **/ + public const LEVEL_CRITICAL = 2; + + /** @var int UNKNOWN level **/ + public const LEVEL_UNKNOWN = 3; + + /** @var int $level The level of this message **/ + public $level = self::LEVEL_UNKNOWN; + + /** @var string $title Title of the message **/ + public $title = ''; + + /** @var string $message Details of this message **/ + public $message = ''; +} + diff --git a/sitecheck.php b/sitecheck.php new file mode 100644 index 0000000..e437877 --- /dev/null +++ b/sitecheck.php @@ -0,0 +1,86 @@ +. + +/** + * Check API Health Check + * + * @package tool_heartbeat + * @copyright 2023 Matthew Hilton + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * This can be run either as a web api, or on the CLI. When run on the + * CLI it conforms to the Nagios plugin standard. + * + * See also: + * - http://nagios.sourceforge.net/docs/3_0/pluginapi.html + * - https://nagios-plugins.org/doc/guidelines.html#PLUGOUTPUT + * + */ + +use tool_heartbeat\checker; +use tool_heartbeat\resultmessage; + +// @codingStandardsIgnoreStart +define('NO_UPGRADE_CHECK', true); + +// Start output buffering. This stops for e.g. debugging messages from breaking the output. +// When a nagios.php send_* function is called, they will collect the buffer +// and warn if it is not empty (but do it nicely). +ob_start(); + +$dirroot = __DIR__ . '/../../../'; +require($dirroot.'config.php'); +require_once(__DIR__.'/nagios.php'); + +global $PAGE; + +if (isset($CFG->mnet_dispatcher_mode) and $CFG->mnet_dispatcher_mode !== 'off') { + // This is a core bug workaround, see MDL-77247 for more details. + require_once($CFG->dirroot.'/mnet/lib.php'); +} + +$messages = checker::get_check_messages(); + +// Filter out any OK ones, we only care about the others. +$messages = array_filter($messages, function($m) { + return $m->level != resultmessage::LEVEL_OK; +}); + +// Construct the output message. +$PAGE->set_context(\context_system::instance()); + +// Indent the messages. +$msg = array_map(function($message) { + global $OUTPUT; + + // Indent messages. + $spacer = "  "; + $indentedmessage = $spacer . str_replace("\n", "\n" . $spacer, $message->message); + + return $OUTPUT->render_from_template('tool_heartbeat/resultmessage', [ + 'prefix' => checker::NAGIOS_PREFIXES[$message->level], + 'title' => $message->title, + 'message' => nl2br($indentedmessage), + ]); +}, $messages); + +$summary = checker::create_summary($messages); +$msg = $summary . "
" . implode("", $msg); + +$level = checker::determine_nagios_level($messages); + +send($level, $msg); + diff --git a/templates/resultmessage.mustache b/templates/resultmessage.mustache new file mode 100644 index 0000000..696ff1f --- /dev/null +++ b/templates/resultmessage.mustache @@ -0,0 +1,40 @@ + +{{! + This file is part of Moodle - https://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see + . + }} + {{! + @template tool_heartbeat/resultmessage + + Template by JS to render output of result report getting (loading, url, error) + + Classes required for JS: + * none + + Context variables required for this template: + * none + + Example context (json): + { + "prefix": "CRTIICAL", + "title": "Something broke", + "message": "Some more details" + } + }} + +* {{prefix}} {{title}}
+ {{{message}}}
+
diff --git a/tests/checker_test.php b/tests/checker_test.php new file mode 100644 index 0000000..ea0f15e --- /dev/null +++ b/tests/checker_test.php @@ -0,0 +1,140 @@ +. + +namespace tool_heartbeat; + +/** + * Test class for tool_heartbeat\checker + * + * @package tool_heartbeat + * @author Matthew Hilton + * @copyright 2023, Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class checker_test extends \advanced_testcase { + /** + * Tests get_check_messages function + */ + public function test_get_check_messages() { + // Check API modifies DB state. + $this->resetAfterTest(true); + + // Just test that the check API is working, and this returns some checks (for example the ones included with this plugin). + $checks = checker::get_check_messages(); + $this->assertNotEmpty($checks); + } + + /** + * Provides values to determine_nagios_level test + * @return array + */ + public static function determine_nagios_level_provider(): array { + return [ + 'no messages' => [ + 'levels' => [], + 'nagioslevel' => resultmessage::LEVEL_OK, + ], + 'one OK message' => [ + 'levels' => [resultmessage::LEVEL_OK], + 'nagioslevel' => resultmessage::LEVEL_OK, + ], + 'one UNKNOWN message' => [ + 'levels' => [resultmessage::LEVEL_UNKNOWN], + 'nagioslevel' => resultmessage::LEVEL_UNKNOWN, + ], + 'one UNKNOWN and one OK' => [ + 'levels' => [resultmessage::LEVEL_UNKNOWN, resultmessage::LEVEL_OK], + 'nagioslevel' => resultmessage::LEVEL_UNKNOWN, + ], + 'one UNKNOWN and one WARNING' => [ + 'levels' => [resultmessage::LEVEL_UNKNOWN, resultmessage::LEVEL_WARN], + 'nagioslevel' => resultmessage::LEVEL_WARN, + ], + 'one UNKNOWN and on CRITICAL' => [ + 'levels' => [resultmessage::LEVEL_UNKNOWN, resultmessage::LEVEL_CRITICAL], + 'nagioslevel' => resultmessage::LEVEL_CRITICAL, + ], + ]; + } + + /** + * Tests determine_nagios_level function + * @param array $levels + * @param int $expectedlevel + * @dataProvider determine_nagios_level_provider + */ + public function test_determine_nagios_level(array $levels, int $expectedlevel) { + // Generate a series of dummy messages with the given levels. + $messages = array_map(function($level) { + $msg = new resultmessage(); + $msg->level = $level; + return $msg; + }, $levels); + + // Confirm the correct level outputted. + $level = checker::determine_nagios_level($messages); + $this->assertEquals($expectedlevel, $level); + } + + /** + * Provides values to test_create_summary test + * @return array + */ + public static function create_summary_provider(): array { + + $warnmsg = new resultmessage(); + $warnmsg->level = resultmessage::LEVEL_WARN; + $warnmsg->title = "test WARN title"; + + $okmsg = new resultmessage(); + $okmsg->level = resultmessage::LEVEL_OK; + $okmsg->title = "test OK title"; + + $criticalmsg = new resultmessage(); + $criticalmsg->level = resultmessage::LEVEL_CRITICAL; + $criticalmsg->title = "test CRITICAL title"; + + return [ + 'no messages (no message displayed)' => [ + 'messages' => [], + 'expectedsummary' => "OK", + ], + 'only OK (no message displayed)' => [ + 'messages' => [$okmsg], + 'expectedsummary' => "OK", + ], + 'only WARNING (shows error in top level)' => [ + 'messages' => [$warnmsg], + 'expectedsummary' => $warnmsg->title, + ], + 'mix of warning levels (shows summary of levels without including OK)' => [ + 'messages' => [$warnmsg, $okmsg, $criticalmsg], + 'expectedsummary' => "Multiple problems detected: 1 WARNING, 1 CRITICAL", + ], + ]; + } + + /** + * Tests create_summary function + * @param array $messages + * @param string $expectedsummary + * @dataProvider create_summary_provider + */ + public function test_create_summary(array $messages, string $expectedsummary) { + $summary = checker::create_summary($messages); + $this->assertEquals($expectedsummary, $summary); + } +}