From 9426ffb12e8102e550cfe130ee4d7526984b9aca Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Tue, 10 Jan 2012 19:28:04 +1300 Subject: [PATCH] Initial release of the module, based on Sam and Will's work --- README.md | 84 +++++++++++++++ _config.php | 25 +++++ code/DefaultHealthChecks.php | 157 ++++++++++++++++++++++++++++ code/DevCheck.php | 10 ++ code/DevHealth.php | 9 ++ code/EnvironmentCheck.php | 175 ++++++++++++++++++++++++++++++++ code/EnvironmentChecker.php | 59 +++++++++++ templates/EnvironmentChecker.ss | 75 ++++++++++++++ 8 files changed, 594 insertions(+) create mode 100644 README.md create mode 100644 _config.php create mode 100644 code/DefaultHealthChecks.php create mode 100644 code/DevCheck.php create mode 100644 code/DevHealth.php create mode 100644 code/EnvironmentCheck.php create mode 100644 code/EnvironmentChecker.php create mode 100644 templates/EnvironmentChecker.ss diff --git a/README.md b/README.md new file mode 100644 index 0000000..df0ea29 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# SilverStripe Environment Checker Module + +Developed by Sam Minnée, thanks to Will Rossiter. + +This module adds an API for running environment checks to your API. + + * `dev/health`` - A public URL that performs a quick check that this environment is functioning. This could be tied to a load balancer, for example. + * `dev/check` - An admin-only URL that performs a more comprehensive set of checks. This could be tied to a deployment system, for example. + +## Aren't these just unit tests? + +Almost, but not really. Environment checks differ from unit tests in two important ways: + + * **They test environment specific settings.** Unit tests are designed to use dummy data and mock interfaces to external system. Environment checks check the real systems and data that the given environment is actually connected to. + * **They can't modify data.** Because these checks will run using production databases, they can't go modifying the data in there. This is the biggest reason why we haven't used the same base class as a unit test for writing environment checks - we wanted to make it impossible to accidentally plug the a unit test into the environment checker! + +## Adding more checks + +To add more checks, you should put additional `EnvironmentCheckSuite::register` calls into your `_config.php`. See the `_config.php` file of this mode for examples. + + :::php + EnvironmentCheckSuite::register('check', 'HasFunctionCheck("curl_init")', "Does PHP have CURL support?"); + EnvironmentCheckSuite::register('check', 'HasFunctionCheck("imagecreatetruecolor")', "Does PHP have GD2 support?"); + +The first argument is the name of the check suite. There are two built-in check suites, "health", and "check", corresponding to the `dev/health` and `dev/check` URLs. If you wish, you can create your own check suites and execute them on other URLs. + +The module comes bundled with a few checks in DefaultHealthChecks.php. However, to test your own application, you probably want to write custom checks. + + * Implement the EnvironmentCheck interface + * Define the check() function, which returns a 2 element array: + * The first element is one of `EnvironmentCheck::OK`, `EnvironmentCheck::WARNING`, `EnvironmentCheck::ERROR`, depending on the status of the check + * The second element is a string describing the response. + +Here is a simple example of how you might create a check to test your own code. In this example, we are checking that an instance of the `MyGateway` class will return "foo" when `call()` is called on it. Testing interfaces with 3rd party systems is a common use case for custom environment checks. + + :::php + class MyGatewayCheck implements EnvironmentCheck { + protected $checkTable; + + function check() { + $g = new MyGateway; + + $response = $g->call(); + $expectedResponse = "foo"; + + if($response == null) { + return array(EnvironmentCheck::ERROR, "MyGateway didn't return a response"); + } else if($response != $expectedResponse) { + return array(EnvironmentCheck::WARNING, "MyGateway returned unexpected response $response"); + } else { + return array(EnvironmentCheck::OK, ""); + } + } + } + +Once you have created your custom check class, don't forget to register it in a check suite + + :::php + EnvironmentCheckSuite::register('check', 'MyGatewayCheck', "Can I connect to the gateway?"); + +### Using other environment check suites + +"health" and "check" are the only environment check suites that we use because they aren + +If you want to use the same UI as dev/health and dev/check, you can create an `EnvironmentChecker` object. This class is a `RequestHandler` and so can be returned from an action handler. The first argument to the `EnvironmentChecker` constructor is the suite name. For example: + + class DevHealth extends Controller { + function index() { + $e = new EnvironmentChecker('health', 'Site health'); + return $e; + } + } + +If you wish to embed an environment check suite in another, you can use the following call. + + $result = EnvironmentCheckSuite::inst("health")->run(); + +`$result` will contain a `EnvironmentCheckSuiteResult` object + + * **`$result->ShouldPass()`:** Return a boolean of whether or not the tests passed. + * **`$result->Status()`:** The string "OK", "WARNING", or "ERROR", depending on the worst failure. + * **`$result->Details()`:** A `DataObjectSet` of details about the result of each check in the suite. + +See `EnvironmentChecker.ss` to see how these can be used to build a UI. \ No newline at end of file diff --git a/_config.php b/_config.php new file mode 100644 index 0000000..4956798 --- /dev/null +++ b/_config.php @@ -0,0 +1,25 @@ + 'DevHealth', + 'dev/check' => 'DevCheck', +)); \ No newline at end of file diff --git a/code/DefaultHealthChecks.php b/code/DefaultHealthChecks.php new file mode 100644 index 0000000..500d6bf --- /dev/null +++ b/code/DefaultHealthChecks.php @@ -0,0 +1,157 @@ +checkTable = $checkTable; + } + + function check() { + $count = DB::query("SELECT COUNT(*) FROM \"$this->checkTable\"")->value(); + + if($count > 0) { + return array(EnvironmentCheck::OK, ""); + } else { + return array(EnvironmentCheck::WARNING, "$this->checkTable queried ok but has no records"); + } + } +} + +/** + * Check that a given URL is functioning, by default, the homepage. + * + * Note that Director::test() will be used rather than a CURL check. + */ +class URLCheck implements EnvironmentCheck { + protected $url; + protected $testString; + + /* + * @param $url The URL to check, relative to the site. "" is the homepage. + * @param $testString A piece of text to optionally search for in the homepage HTML. If omitted, no such check is made. + */ + function __construct($url = "", $testString = "") { + $this->url = $url; + $this->testString = $testString; + } + + function check() { + $response = Director::test($this->url); + + if($response->getStatusCode() != 200) { + return array(EnvironmentCheck::ERROR, "Homepage requested and returned HTTP " . $response->getStatusCode() . " response"); + + } else if($this->testString && (strpos($response->getBody(), $this->testString) === false)) { + return array(EnvironmentCheck::WARNING, "Homepage requested ok but '$testString' not found."); + + } else { + return array(EnvironmentCheck::OK, ""); + } + } +} + +/** + * Check that the given function exists. + * This can be used to check that PHP modules or features are installed. + * @param $functionName The name of the function to look for. + */ +class HasFunctionCheck implements EnvironmentCheck { + protected $functionName; + + function __construct($functionName) { + $this->functionName = $functionName; + } + + function check() { + if(function_exists($this->functionName)) return array(EnvironmentCheck::OK, $this->functionName.'() exists'); + else return array(EnvironmentCheck::ERROR, $this->functionName.'() doesn\'t exist'); + } +} + +/** + * Check that the given class exists. + * This can be used to check that PHP modules or features are installed. + * @param $className The name of the class to look for. + */ +class HasClassCheck implements EnvironmentCheck { + protected $className; + + function __construct($className) { + $this->className = $className; + } + + function check() { + if(class_exists($this->className)) return array(EnvironmentCheck::OK, 'Class ' . $this->className.' exists'); + else return array(EnvironmentCheck::ERROR, 'Class ' . $this->className.' doesn\'t exist'); + } +} + +/** + * Check that the given file is writeable. + * This can be used to check that the environment doesn't have permission set-up errors. + * @param $path The full path. If a relative path, it will relative to the BASE_PATH + */ +class FileWriteableCheck implements EnvironmentCheck { + protected $path; + + function __construct($path) { + $this->path = $path; + } + + function check() { + if($this->path[0] == '/') $filename = $this->path; + else $filename = BASE_PATH . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $this->path); + + if(file_exists($filename)) $isWriteable = is_writeable($filename); + else $isWriteable = is_writeable(dirname($filename)); + + if(!$isWriteable) { + if(function_exists('posix_getgroups')) { + $userID = posix_geteuid(); + $user = posix_getpwuid($userID); + + $currentOwnerID = fileowner(file_exists($filename) ? $filename : dirname($filename) ); + $currentOwner = posix_getpwuid($currentOwnerID); + + $message = "User '$user[name]' needs to be able to write to this file:\n$filename\n\nThe file is currently owned by '$currentOwner[name]'. "; + + if($user['name'] == $currentOwner['name']) { + $message .= "We recommend that you make the file writeable."; + } else { + + $groups = posix_getgroups(); + $groupList = array(); + foreach($groups as $group) { + $groupInfo = posix_getgrgid($group); + if(in_array($currentOwner['name'], $groupInfo['members'])) $groupList[] = $groupInfo['name']; + } + if($groupList) { + $message .= " We recommend that you make the file group-writeable and change the group to one of these groups:\n - ". implode("\n - ", $groupList) + . "\n\nFor example:\nchmod g+w $filename\nchgrp " . $groupList[0] . " $filename"; + } else { + $message .= " There is no user-group that contains both the web-server user and the owner of this file. Change the ownership of the file, create a new group, or temporarily make the file writeable by everyone during the install process."; + } + } + + } else { + $message .= "The webserver user needs to be able to write to this file:\n$filename"; + } + + return array(EnvironmentCheck::ERROR, $message); + } + + return array(EnvironmentCheck::OK,''); + } +} \ No newline at end of file diff --git a/code/DevCheck.php b/code/DevCheck.php new file mode 100644 index 0000000..8df6086 --- /dev/null +++ b/code/DevCheck.php @@ -0,0 +1,10 @@ +setErrorCode(404); + return $e; + } +} \ No newline at end of file diff --git a/code/EnvironmentCheck.php b/code/EnvironmentCheck.php new file mode 100644 index 0000000..12d785e --- /dev/null +++ b/code/EnvironmentCheck.php @@ -0,0 +1,175 @@ +run(); + */ +class EnvironmentCheckSuite { + protected $checks = array(); + + /** + * Run this test suite + * @return The result code of the worst result. + */ + public function run() { + $worstResult = 0; + + $result = new EnvironmentCheckSuiteResult; + foreach($this->checkInstances() as $check) { + list($checkClass, $checkTitle) = $check; + try { + list($status, $message) = $checkClass->check(); + // If the check fails, register that as an error + } catch(Exception $e) { + $status = EnvironmentCheck::ERROR; + $message = $e->getMessage(); + } + $result->addResult($status, $message, $checkTitle); + } + + return $result; + } + + /** + * Get instances of all the environment checks + */ + protected function checkInstances() { + $output = array(); + foreach($this->checks as $check) { + list($checkClass, $checkTitle) = $check; + if(is_string($checkClass)) { + $checkInst = Object::create_from_string($checkClass); + if($checkInst instanceof EnvironmentCheck) { + $output[] = array($checkInst, $checkTitle); + } else { + throw new InvalidArgumentException("Bad EnvironmentCheck: '$checkClass' - the named class doesn't implement EnvironmentCheck"); + } + } else if($checkClass instanceof EnvironmentCheck) { + $output[] = array($checkClass, $checkTitle); + } else { + throw new InvalidArgumentException("Bad EnvironmentCheck: " . var_export($check, true)); + } + } + return $output; + } + + /** + * Add a check to this suite. + * + */ + public function push($check, $title = null) { + if(!$title) { + $title = is_string($check) ? $check : get_class($check); + } + $this->checks[] = array($check, $title); + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + + protected static $instances = array(); + + /** + * Return a named instance of EnvironmentCheckSuite. + */ + static function inst($name) { + if(!isset(self::$instances[$name])) self::$instances[$name] = new EnvironmentCheckSuite(); + return self::$instances[$name]; + } + + /** + * Register a check against the named check suite + */ + static function register($name, $check, $title = null) { + self::inst($name)->push($check, $title); + } +} + +/** + * A single set of results from running an EnvironmentCheckSuite + */ +class EnvironmentCheckSuiteResult extends ViewableData { + protected $details, $worst = 0; + + function __construct() { + parent::__construct(); + $this->details = new DataObjectSet(); + } + + function addResult($status, $message, $checkIdentifier) { + $this->details->push(new ArrayData(array( + 'Check' => $checkIdentifier, + 'Status' => $this->statusText($status), + 'Message' => $message, + ))); + + $this->worst = max($this->worst, $status); + } + + /** + * Returns true if there are no ERRORs, only WARNINGs or OK + */ + function ShouldPass() { + return $this->worst <= EnvironmentCheck::WARNING; + } + + /** + * Returns overall (i.e. worst) status as a string. + */ + function Status() { + return $this->statusText($this->worst); + } + + /** + * Returns detailed status information about each check + */ + function Details() { + return $this->details; + } + + /** + * Return a text version of a status code + */ + protected function statusText($status) { + switch($status) { + case EnvironmentCheck::ERROR: return "ERROR"; + case EnvironmentCheck::WARNING: return "WARNING"; + case EnvironmentCheck::OK: return "OK"; + case 0: return "NO CHECKS"; + default: throw new InvalidArgumentException("Bad environment check status '$status'"); + } + } +} \ No newline at end of file diff --git a/code/EnvironmentChecker.php b/code/EnvironmentChecker.php new file mode 100644 index 0000000..72a4395 --- /dev/null +++ b/code/EnvironmentChecker.php @@ -0,0 +1,59 @@ + 'index', + ); + + protected $checkSuiteName; + protected $title; + + protected $errorCode = 500; + + function __construct($checkSuiteName, $title) { + parent::__construct(); + + $this->checkSuiteName = $checkSuiteName; + $this->title = $title; + } + + /** + * Set the HTTP status code that should be returned when there's an error. + * Defaults to 500 + */ + function setErrorCode($errorCode) { + $this->errorCode = $errorCode; + } + + function init() { + parent::init(); + + // We allow access to this controller regardless of live-status or ADMIN permission only + // if on CLI. Access to this controller is always allowed in "dev-mode", or of the user is ADMIN. + $canAccess = (Director::isDev() + || Director::is_cli() + // Its important that we don't run this check if dev/build was requested + || Permission::check("ADMIN") + ); + if(!$canAccess) return Security::permissionFailure($this); + } + + function index() { + $response = new SS_HTTPResponse; + $result = EnvironmentCheckSuite::inst($this->checkSuiteName)->run(); + + if(!$result->ShouldPass()) { + $response->setStatusCode($this->errorCode); + } + + $response->setBody($result->customise(array( + "Title" => $this->title, + "ErrorCode" => $this->errorCode, + ))->renderWith("EnvironmentChecker")); + + return $response; + } +} \ No newline at end of file diff --git a/templates/EnvironmentChecker.ss b/templates/EnvironmentChecker.ss new file mode 100644 index 0000000..ac509ee --- /dev/null +++ b/templates/EnvironmentChecker.ss @@ -0,0 +1,75 @@ + + + Site health: $Status + + + + + +

$Title: $Status

+ + + + <% control Details %> + + <% end_control %> +
Check Status Message
$Check $Status $Message.XML
+ + <% if ShouldPass %> +

Site is available

+

(you may check for the presence of the text 'Site is available' rather than an HTTP $ErrorCode error on this page, if you prefer.)

+ <% else %> +

Site is not available

+ <% end_if %> + + \ No newline at end of file